├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── docker.yml │ ├── mypy.yml │ ├── python-build.yml │ ├── python-test.yml │ ├── sync-wiki.yml │ ├── update_version.py │ └── versioning.yml ├── .gitignore ├── CITATION.cff ├── LICENSE ├── README.md ├── config.yml.default ├── docker ├── Dockerfile ├── Dockerfile.dockerignore └── copy_files.sh ├── docs ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── SECURITY.md ├── engines └── README.md ├── extra_game_handlers.py ├── homemade.py ├── lib ├── __init__.py ├── config.py ├── conversation.py ├── engine_wrapper.py ├── lichess.py ├── lichess_bot.py ├── lichess_types.py ├── matchmaking.py ├── model.py ├── timer.py └── versioning.yml ├── lichess-bot.py ├── requirements.txt ├── test_bot ├── __init__.py ├── buggy_engine ├── buggy_engine.bat ├── buggy_engine.py ├── buggy_engine_macos ├── conftest.py ├── homemade.py ├── lichess.py ├── ruff.toml ├── test-requirements.txt ├── test_bot.py ├── test_external_moves.py ├── test_lichess.py ├── test_model.py └── test_timer.py └── wiki ├── Configure-lichess-bot.md ├── Create-a-homemade-engine.md ├── Extra-customizations.md ├── How-to-Install.md ├── How-to-Run-lichess‐bot.md ├── How-to-create-a-Lichess-OAuth-token.md ├── How-to-use-the-Docker-image.md ├── Setup-the-engine.md └── Upgrade-to-a-BOT-account.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize line endings 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Run lichess-bot with [...] commands [e.g. `python lichess-bot.py -v`] 16 | 2. See error 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Logs** 22 | Upload `lichess_bot_auto_logs\lichess-bot.log`, older `lichess-bot.log.` files as needed, `config.log` for your bot's configuration, and other logs/screenshots of the error. If you upload `config.yml` or other configuration file, be sure to delete the token before uploading. 23 | 24 | **Desktop (please complete the following information):** 25 | - OS: [e.g. Windows] 26 | - Python Version: [e.g. 3.11] 27 | - Terminal: [e.g. Command Line, PowerShell] 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | versioning-strategy: increase 13 | 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "daily" 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Type of pull request: 2 | - [ ] Bug fix 3 | - [ ] Feature 4 | - [ ] Other 5 | 6 | ## Description: 7 | 8 | [Provide a brief description of the changes introduced by this pull request.] 9 | 10 | ## Related Issues: 11 | 12 | [Reference any related issues that this pull request addresses or closes. Use the syntax `Closes #issue_number` to automatically close the linked issue upon merging.] 13 | 14 | ## Checklist: 15 | 16 | - [ ] I have read and followed the [contribution guidelines](/docs/CONTRIBUTING.md). 17 | - [ ] I have added necessary documentation (if applicable). 18 | - [ ] The changes pass all existing tests. 19 | 20 | ## Screenshots/logs (if applicable): 21 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker CI 2 | 3 | on: 4 | workflow_run: 5 | workflows: [Versioning] 6 | types: 7 | - completed 8 | 9 | env: 10 | GB_REGISTRY: ghcr.io 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Assign defaultValue 20 | run: | 21 | version=$(grep -m1 '^lichess_bot_version: ' lib/versioning.yml | sed -n -e 's/^.*://p' | tr -d '[:space:]') 22 | echo version="$version" >> "$GITHUB_ENV" 23 | 24 | - name: show me docker tags. 25 | run: | 26 | echo "version:'"$version"'" 27 | 28 | - name: Login to Docker Hub 29 | uses: docker/login-action@v3 30 | with: 31 | username: ${{ secrets.DOCKER_USER }} 32 | password: ${{ secrets.DOCKER_TOKEN }} 33 | 34 | - name: Login to Github container registry 35 | uses: docker/login-action@v3 36 | with: 37 | registry: ${{ env.GB_REGISTRY }} 38 | username: ${{ secrets.GB_REPO_USER }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | - name: Set up QEMU 42 | uses: docker/setup-qemu-action@v3 43 | 44 | - name: Set up Docker Buildx 45 | uses: docker/setup-buildx-action@v3 46 | 47 | - name: build and push alpine image 48 | uses: docker/build-push-action@v6 49 | with: 50 | context: . 51 | file: ./docker/Dockerfile 52 | platforms: | 53 | linux/amd64 54 | linux/arm64 55 | build-args: VARIANT=-alpine 56 | push: true 57 | tags: | 58 | ${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:alpine 59 | ${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }}-alpine 60 | ${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:alpine 61 | ${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }}-alpine 62 | 63 | - name: build and push fat image 64 | uses: docker/build-push-action@v6 65 | with: 66 | context: . 67 | file: ./docker/Dockerfile 68 | platforms: | 69 | linux/amd64 70 | linux/arm64 71 | push: true 72 | tags: | 73 | ${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:latest 74 | ${{ vars.DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }} 75 | ${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:latest 76 | ${{ env.GB_REGISTRY }}/${{ vars.GHCR_DOCKER_ORGANIZATION }}/${{ vars.DOCKER_IMG }}:${{ env.version }} 77 | 78 | - name: push README to Dockerhub 79 | uses: christian-korneck/update-container-description-action@v1 80 | env: 81 | DOCKER_USER: ${{ secrets.DOCKER_USER }} 82 | DOCKER_PASS: ${{ secrets.DOCKER_TOKEN }} 83 | with: 84 | destination_container_repo: lichessbotdevs/lichess-bot 85 | provider: dockerhub 86 | short_description: 'A bridge between Lichess API and chess engines' 87 | readme_file: './wiki/How-to-use-the-Docker-image.md' 88 | -------------------------------------------------------------------------------- /.github/workflows/mypy.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies and run mypy 2 | 3 | name: Mypy 4 | 5 | on: 6 | push: 7 | branches: [ master ] 8 | pull_request: 9 | branches: [ master ] 10 | 11 | jobs: 12 | mypy: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [windows-latest] 17 | python: [3.9, "3.13"] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install -r requirements.txt 29 | pip install -r test_bot/test-requirements.txt 30 | - name: Run mypy 31 | run: | 32 | mypy --strict . 33 | -------------------------------------------------------------------------------- /.github/workflows/python-build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies and lint 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python Build 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | python: [3.9, "3.10", "3.11", "3.12", "3.13"] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements.txt 30 | pip install -r test_bot/test-requirements.txt 31 | - name: Lint with ruff 32 | run: | 33 | # Check for python syntax errors and inconsistent code style. 34 | ruff check --config test_bot/ruff.toml 35 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies and run tests 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python Test 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | test: 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, windows-latest, macos-latest] 18 | python: [3.9, "3.13"] 19 | 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r requirements.txt 31 | pip install -r test_bot/test-requirements.txt 32 | - name: Restore engines 33 | id: cache-temp-restore 34 | uses: actions/cache/restore@v4 35 | with: 36 | path: | 37 | TEMP 38 | key: ${{ matrix.os }}-engines 39 | - name: Test with pytest 40 | env: 41 | LICHESS_BOT_TEST_TOKEN: ${{secrets.LICHESS_BOT_TEST_TOKEN}} 42 | run: | 43 | pytest --log-cli-level=10 44 | - name: Save engines 45 | id: cache-temp-save 46 | uses: actions/cache/save@v4 47 | with: 48 | path: | 49 | TEMP 50 | key: ${{ steps.cache-temp-restore.outputs.cache-primary-key }} 51 | -------------------------------------------------------------------------------- /.github/workflows/sync-wiki.yml: -------------------------------------------------------------------------------- 1 | name: Sync wiki 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'wiki/**' 9 | - 'README.md' 10 | 11 | jobs: 12 | sync: 13 | if: github.repository == 'lichess-bot-devs/lichess-bot' 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout Repository 17 | uses: actions/checkout@v4 18 | - name: Checkout wiki 19 | uses: actions/checkout@v4 20 | with: 21 | repository: "${{ github.repository }}.wiki" 22 | path: "lichess-bot.wiki" 23 | - name: Set github bot global config 24 | run: | 25 | git config --global user.email "actions@github.com" 26 | git config --global user.name "actions-user" 27 | - name: Sync all files to wiki 28 | run: | 29 | cp $GITHUB_WORKSPACE/README.md $GITHUB_WORKSPACE/lichess-bot.wiki/Home.md 30 | cp -r $GITHUB_WORKSPACE/wiki/* $GITHUB_WORKSPACE/lichess-bot.wiki 31 | cd $GITHUB_WORKSPACE/lichess-bot.wiki 32 | git add . 33 | git commit -m "Auto update wiki" 34 | git push 35 | -------------------------------------------------------------------------------- /.github/workflows/update_version.py: -------------------------------------------------------------------------------- 1 | """Automatically updates the lichess-bot version.""" 2 | import yaml 3 | import datetime 4 | import os 5 | 6 | # File is part of an implicit namespace package. Add an `__init__.py`. 7 | # ruff: noqa: INP001 8 | 9 | with open("lib/versioning.yml") as version_file: 10 | versioning_info = yaml.safe_load(version_file) 11 | 12 | current_version = versioning_info["lichess_bot_version"] 13 | 14 | utc_datetime = datetime.datetime.now(datetime.UTC) 15 | new_version = f"{utc_datetime.year}.{utc_datetime.month}.{utc_datetime.day}." 16 | if current_version.startswith(new_version): 17 | current_version_list = current_version.split(".") 18 | current_version_list[-1] = str(int(current_version_list[-1]) + 1) 19 | new_version = ".".join(current_version_list) 20 | else: 21 | new_version += "1" 22 | 23 | versioning_info["lichess_bot_version"] = new_version 24 | 25 | with open("lib/versioning.yml", "w") as version_file: 26 | yaml.dump(versioning_info, version_file, sort_keys=False) 27 | 28 | with open(os.environ["GITHUB_OUTPUT"], "a") as fh: 29 | print(f"new_version={new_version}", file=fh) 30 | -------------------------------------------------------------------------------- /.github/workflows/versioning.yml: -------------------------------------------------------------------------------- 1 | name: Versioning 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | versioning: 9 | if: github.repository == 'lichess-bot-devs/lichess-bot' 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 3.11 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.11" 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install PyYAML 21 | - name: Update version 22 | id: new-version 23 | run: python .github/workflows/update_version.py 24 | - name: Auto update version 25 | run: | 26 | git config --global user.email "actions@github.com" 27 | git config --global user.name "actions-user" 28 | git add lib/versioning.yml 29 | git commit -m "Auto update version to ${{ steps.new-version.outputs.new_version }}" 30 | git push origin HEAD:master 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.yml 2 | **/__pycache__ 3 | /engines/* 4 | !/engines/README.md 5 | lichess_bot_auto_logs/* 6 | .vscode/* 7 | .vs/* 8 | *.pgn 9 | *.log 10 | *.txt 11 | TEMP/* 12 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | # This CITATION.cff file was generated with cffinit. 2 | # Visit https://bit.ly/cffinit to generate yours today! 3 | 4 | cff-version: 1.2.0 5 | title: lichess-bot 6 | message: >- 7 | If you use this software, please cite it using the 8 | metadata from this file. 9 | type: software 10 | authors: 11 | - given-names: Ioannis 12 | family-names: Pantidis 13 | - given-names: Mark 14 | family-names: Harrison 15 | orcid: 'https://orcid.org/0000-0001-7212-8810' 16 | - given-names: Shail 17 | family-names: Choksi 18 | - given-names: Thibault 19 | family-names: Duplessis 20 | affiliation: lichess.org 21 | repository-code: 'https://github.com/lichess-bot-devs/lichess-bot' 22 | abstract: >- 23 | lichess-bot is a free bridge between the Lichess Bot API 24 | and chess engines. 25 | 26 | With lichess-bot, you can create and operate a bot on 27 | lichess. Your bot will be able to play against humans and 28 | bots alike, and you will be able to view these games live 29 | on lichess. 30 | keywords: 31 | - chess 32 | - chess engine 33 | - lichess 34 | - API 35 | - game 36 | license: AGPL-3.0 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![lichess-bot](https://github.com/lichess-bot-devs/lichess-bot-images/blob/main/lichess-bot-icon-400.png) 4 | 5 |

lichess-bot

6 | 7 | A bridge between [lichess.org](https://lichess.org) and bots. 8 |
9 | [Explore lichess-bot docs »](https://github.com/lichess-bot-devs/lichess-bot/wiki) 10 |
11 |
12 | [![Python Build](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-build.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-build.yml) 13 | [![Python Test](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-test.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/python-test.yml) 14 | [![Mypy](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/mypy.yml/badge.svg)](https://github.com/lichess-bot-devs/lichess-bot/actions/workflows/mypy.yml) 15 | 16 |
17 | 18 | ## Overview 19 | 20 | [lichess-bot](https://github.com/lichess-bot-devs/lichess-bot) is a free bridge 21 | between the [Lichess Bot API](https://lichess.org/api#tag/Bot) and chess engines. 22 | 23 | With lichess-bot, you can create and operate a bot on lichess. Your bot will be able to play against humans and bots alike, and you will be able to view these games live on lichess. 24 | 25 | See also the lichess-bot [documentation](https://github.com/lichess-bot-devs/lichess-bot/wiki) for further usage help. 26 | 27 | ## Features 28 | Supports: 29 | - Every variant and time control 30 | - UCI, XBoard, and Homemade engines 31 | - Matchmaking (challenging other bots) 32 | - Offering Draws and Resigning 33 | - Participating in tournaments 34 | - Accepting move takeback requests from opponents 35 | - Saving games as PGN 36 | - Local & Online Opening Books 37 | - Local & Online Endgame Tablebases 38 | 39 | Can run on: 40 | - Python 3.9 and later 41 | - Windows, Linux and MacOS 42 | - Docker 43 | 44 | ## Steps 45 | 1. [Install lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Install) 46 | 2. [Create a lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) 47 | 3. [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine) 48 | 4. [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot) 49 | 5. [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account) 50 | 6. [Run lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Run-lichess%E2%80%90bot) 51 | 52 | ## Advanced options 53 | - [Create a homemade engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-homemade-engine) 54 | - [Add extra customizations](https://github.com/lichess-bot-devs/lichess-bot/wiki/Extra-customizations) 55 | 56 |
57 | 58 | ## Acknowledgements 59 | Thanks to the Lichess team, especially T. Alexander Lystad and Thibault Duplessis for working with the LeelaChessZero team to get this API up. Thanks to the [Niklas Fiekas](https://github.com/niklasf) and his [python-chess](https://github.com/niklasf/python-chess) code which allows engine communication seamlessly. 60 | 61 | ## License 62 | lichess-bot is licensed under the AGPLv3 (or any later version at your option). Check out the [LICENSE file](https://github.com/lichess-bot-devs/lichess-bot/blob/master/LICENSE) for the full text. 63 | 64 | ## Citation 65 | If this software has been used for research purposes, please cite it using the "Cite this repository" menu on the right sidebar. For more information, check the [CITATION file](https://github.com/lichess-bot-devs/lichess-bot/blob/master/CITATION.cff). 66 | -------------------------------------------------------------------------------- /config.yml.default: -------------------------------------------------------------------------------- 1 | token: "xxxxxxxxxxxxxxxxxxxxxx" # Lichess OAuth2 Token. 2 | url: "https://lichess.org/" # Lichess base URL. 3 | 4 | engine: # Engine settings. 5 | dir: "./engines/" # Directory containing the engine. This can be an absolute path or one relative to lichess-bot/. 6 | name: "engine_name" # Binary name of the engine to use. 7 | # interpreter: "java" 8 | # interpreter_options: 9 | # - "-jar" 10 | working_dir: "" # Directory where the chess engine will read and write files. If blank or missing, the current directory is used. 11 | # NOTE: If working_dir is set, the engine will look for files and directories relative to this directory, not where lichess-bot was launched. Absolute paths are unaffected. 12 | protocol: "uci" # "uci", "xboard" or "homemade" 13 | ponder: true # Think on opponent's time. 14 | 15 | polyglot: 16 | enabled: false # Activate polyglot book. 17 | book: 18 | standard: # List of book file paths for variant standard. 19 | - engines/book1.bin 20 | - engines/book2.bin 21 | # atomic: # List of book file paths for variant atomic. 22 | # - engines/atomicbook1.bin 23 | # - engines/atomicbook2.bin 24 | # etc. 25 | # Use the same pattern for 'chess960', 'giveaway' (antichess), 'crazyhouse', 'horde', 'kingofthehill', 'racingkings' and '3check' as well. 26 | min_weight: 1 # Does not select moves with weight below min_weight (min 0, max: 100 if normalization isn't "none" else 65535). 27 | selection: "weighted_random" # Move selection is one of "weighted_random", "uniform_random" or "best_move" (but not below the min_weight in the 2nd and 3rd case). 28 | max_depth: 20 # How many moves from the start to take from the book. 29 | normalization: "none" # Normalization method for the book weights. One of "none", "sum", or "max". 30 | 31 | draw_or_resign: 32 | resign_enabled: false # Whether or not the bot should resign. 33 | resign_score: -1000 # If the score is less than or equal to this value, the bot resigns (in cp). 34 | resign_for_egtb_minus_two: true # If true the bot will resign in positions where the online_egtb returns a wdl of -2. 35 | resign_moves: 3 # How many moves in a row the score has to be below the resign value. 36 | offer_draw_enabled: true # Whether or not the bot should offer/accept draw. 37 | offer_draw_score: 0 # If the absolute value of the score is less than or equal to this value, the bot offers/accepts draw (in cp). 38 | offer_draw_for_egtb_zero: true # If true the bot will offer/accept draw in positions where the online_egtb returns a wdl of 0. 39 | offer_draw_moves: 10 # How many moves in a row the absolute value of the score has to be below the draw value. 40 | offer_draw_pieces: 10 # Only if the pieces on board are less than or equal to this value, the bot offers/accepts draw. 41 | 42 | online_moves: 43 | max_out_of_book_moves: 10 # Stop using online opening books after they don't have a move for 'max_out_of_book_moves' positions. Doesn't apply to the online endgame tablebases. 44 | max_retries: 2 # The maximum amount of retries when getting an online move. 45 | # max_depth: 10 # How many moves from the start to take from online books. Default is no limit. 46 | chessdb_book: 47 | enabled: false # Whether or not to use chessdb book. 48 | min_time: 20 # Minimum time (in seconds) to use chessdb book. 49 | move_quality: "good" # One of "all", "good", "best". 50 | min_depth: 20 # Only for move_quality: "best". 51 | lichess_cloud_analysis: 52 | enabled: false # Whether or not to use lichess cloud analysis. 53 | min_time: 20 # Minimum time (in seconds) the bot must have to use cloud analysis. 54 | move_quality: "best" # One of "good", "best". 55 | max_score_difference: 50 # Only for move_quality: "good". The maximum score difference (in cp) between the best move and the other moves. 56 | min_depth: 20 57 | min_knodes: 0 58 | lichess_opening_explorer: 59 | enabled: false 60 | min_time: 20 61 | source: "masters" # One of "lichess", "masters", "player" 62 | player_name: "" # The lichess username. Leave empty for the bot's username to be used. Used only when source is "player". 63 | sort: "winrate" # One of "winrate", "games_played" 64 | min_games: 10 # Minimum number of times a move must have been played to be chosen. 65 | online_egtb: 66 | enabled: false # Whether or not to enable online endgame tablebases. 67 | min_time: 20 # Minimum time (in seconds) the bot must have to use online EGTBs. 68 | max_pieces: 7 # Maximum number of pieces on the board to use endgame tablebases. 69 | source: "lichess" # One of "lichess", "chessdb". 70 | move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move). 71 | 72 | lichess_bot_tbs: # The tablebases list here will be read by lichess-bot, not the engine. 73 | syzygy: 74 | enabled: false # Whether or not to use local syzygy endgame tablebases. 75 | paths: # Paths to Syzygy endgame tablebases. 76 | - "engines/syzygy" 77 | max_pieces: 7 # Maximum number of pieces in the endgame tablebase. 78 | move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move). 79 | gaviota: 80 | enabled: false # Whether or not to use local gaviota endgame tablebases. 81 | paths: 82 | - "engines/gaviota" 83 | max_pieces: 5 84 | min_dtm_to_consider_as_wdl_1: 120 # The minimum DTM to consider as syzygy WDL=1/-1. Set to 100 to disable. 85 | move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move). 86 | 87 | # engine_options: # Any custom command line params to pass to the engine. 88 | # cpuct: 3.1 89 | 90 | homemade_options: 91 | # Hash: 256 92 | 93 | uci_options: # Arbitrary UCI options passed to the engine. 94 | Move Overhead: 100 # Increase if your bot flags games too often. 95 | Threads: 4 # Max CPU threads the engine can use. 96 | Hash: 512 # Max memory (in megabytes) the engine can allocate. 97 | SyzygyPath: "./syzygy/" # Paths to Syzygy endgame tablebases that the engine reads. 98 | UCI_ShowWDL: true # Show the chance of the engine winning. 99 | # go_commands: # Additional options to pass to the UCI go command. 100 | # nodes: 1 # Search so many nodes only. 101 | # depth: 5 # Search depth ply only. 102 | # movetime: 1000 # Integer. Search exactly movetime milliseconds. 103 | 104 | # xboard_options: # Arbitrary XBoard options passed to the engine. 105 | # cores: "4" 106 | # memory: "4096" 107 | # egtpath: # Directory containing egtb (endgame tablabases), relative to this project. For 'xboard' engines. 108 | # gaviota: "Gaviota path" 109 | # nalimov: "Nalimov Path" 110 | # scorpio: "Scorpio Path" 111 | # syzygy: "Syzygy Path" 112 | # go_commands: # Additional options to pass to the XBoard go command. 113 | # depth: 5 # Search depth ply only. 114 | # Do note that the go commands 'movetime' and 'nodes' are invalid and may cause bad time management for XBoard engines. 115 | 116 | silence_stderr: false # Some engines (yes you, Leela) are very noisy. 117 | 118 | abort_time: 30 # Time to abort a game in seconds when there is no activity. 119 | fake_think_time: false # Artificially slow down the bot to pretend like it's thinking. 120 | rate_limiting_delay: 0 # Time (in ms) to delay after sending a move to prevent "Too Many Requests" errors. 121 | move_overhead: 2000 # Increase if your bot flags games too often. 122 | max_takebacks_accepted: 0 # The number of times to allow an opponent to take back a move in a game. 123 | quit_after_all_games_finish: false # If set to true, then pressing Ctrl-C to quit will only stop lichess-bot after all current games have finished. 124 | 125 | correspondence: 126 | move_time: 60 # Time in seconds to search in correspondence games. 127 | checkin_period: 300 # How often to check for opponent moves in correspondence games after disconnecting. 128 | disconnect_time: 150 # Time before disconnecting from a correspondence game. 129 | ponder: false # Ponder in correspondence games the bot is connected to. 130 | 131 | challenge: # Incoming challenges. 132 | concurrency: 1 # Number of games to play simultaneously. 133 | sort_by: "best" # Possible values: "best" and "first". 134 | preference: "none" # Possible values: "none", "human", "bot". 135 | accept_bot: true # Accepts challenges coming from other bots. 136 | only_bot: false # Accept challenges by bots only. 137 | max_increment: 20 # Maximum amount of increment to accept a challenge in seconds. The max is 180. Set to 0 for no increment. 138 | min_increment: 0 # Minimum amount of increment to accept a challenge in seconds. 139 | max_base: 1800 # Maximum amount of base time to accept a challenge in seconds. The max is 10800 (3 hours). 140 | min_base: 0 # Minimum amount of base time to accept a challenge in seconds. 141 | max_days: 14 # Maximum number of days per move to accept a challenge for a correspondence game. 142 | # Unlimited games can be accepted by removing this field or specifying .inf 143 | min_days: 1 # Minimum number of days per move to accept a challenge for a correspondence game. 144 | variants: # Chess variants to accept (https://lichess.org/variant). 145 | - standard 146 | # - fromPosition 147 | # - antichess 148 | # - atomic 149 | # - chess960 150 | # - crazyhouse 151 | # - horde 152 | # - kingOfTheHill 153 | # - racingKings 154 | # - threeCheck 155 | time_controls: # Time controls to accept (bots are not allowed to play ultraBullet). 156 | - bullet 157 | - blitz 158 | - rapid 159 | - classical 160 | # - correspondence 161 | modes: # Game modes to accept. 162 | - casual # Unrated games. 163 | - rated # Rated games - must comment if the engine doesn't try to win. 164 | # block_list: # List of users from which the challenges are always declined. 165 | # - user1 166 | # - user2 167 | # online_block_list: # The urls from which to retrieve a list of bot names that will not be challenged. The list should be a text file where each line contains the name of a blocked bot 168 | # - example.com/blocklist 169 | # allow_list: # List of users from which challenges are exclusively accepted, all others being declined. If empty, challenges from all users may be accepted. 170 | # - user3 171 | # - user4 172 | # recent_bot_challenge_age: 60 # Maximum age of a bot challenge to be considered recent in seconds 173 | # max_recent_bot_challenges: 2 # Maximum number of recent challenges that can be accepted from the same bot 174 | bullet_requires_increment: false # Require that bullet game challenges from bots have a non-zero increment 175 | max_simultaneous_games_per_user: 5 # Maximum number of simultaneous games with the same user 176 | 177 | greeting: 178 | # Optional substitution keywords (include curly braces): 179 | # {opponent} to insert opponent's name 180 | # {me} to insert bot's name 181 | # Any other words in curly braces will be removed. 182 | hello: "Hi! I'm {me}. Good luck! Type !help for a list of commands I can respond to." # Message to send to opponent chat at the start of a game 183 | goodbye: "Good game!" # Message to send to opponent chat at the end of a game 184 | hello_spectators: "Hi! I'm {me}. Type !help for a list of commands I can respond to." # Message to send to spectator chat at the start of a game 185 | goodbye_spectators: "Thanks for watching!" # Message to send to spectator chat at the end of a game 186 | 187 | # pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept 188 | # pgn_file_grouping: "game" # How to group games into files. Options are "game", "opponent", and "all" 189 | # "game" (default) - every game is written to a different file named "{White name} vs. {Black name} - {lichess game ID}.pgn" 190 | # "opponent" - every game with a given opponent is written to a file named "{Bot name} games vs. {Opponent name}.pgn" 191 | # "all" - every game is written to a single file named "{Bot name} games.pgn" 192 | 193 | matchmaking: 194 | allow_matchmaking: false # Set it to 'true' to challenge other bots. 195 | allow_during_games: false # Set it to 'true' to create challenges during long games. 196 | challenge_variant: "random" # If set to 'random', the bot will choose one variant from the variants enabled in 'challenge.variants'. 197 | challenge_timeout: 30 # Create a challenge after being idle for 'challenge_timeout' minutes. The minimum is 1 minute. 198 | challenge_initial_time: # Initial time in seconds of the challenge (to be chosen at random). 199 | - 60 200 | - 180 201 | challenge_increment: # Increment in seconds of the challenge (to be chosen at random). 202 | - 1 203 | - 2 204 | # challenge_days: # Days for correspondence challenge (to be chosen at random). 205 | # - 1 206 | # - 2 207 | # opponent_min_rating: 600 # Opponents rating should be above this value (600 is the minimum rating in lichess). 208 | # opponent_max_rating: 4000 # Opponents rating should be below this value (4000 is the maximum rating in lichess). 209 | opponent_rating_difference: 300 # The maximum difference in rating between the bot's rating and opponent's rating. 210 | rating_preference: "none" # One of "none", "high", "low". 211 | challenge_mode: "random" # Set it to the mode in which challenges are sent. Possible options are 'casual', 'rated' and 'random'. 212 | challenge_filter: none # If a bot declines a challenge, do not issue a similar challenge to that bot. Possible options are 'none', 'coarse', and 'fine'. 213 | # block_list: # The list of bots that will not be challenged 214 | # - user1 215 | # - user2 216 | # online_block_list: # The urls from which to retrieve a list of bot names that will not be challenged. The list should be a text file where each line contains the name of a blocked bot 217 | # - example.com/blocklist 218 | include_challenge_block_list: false # Do not challenge bots in the challenge: block_list in addition to the matchmaking block list. 219 | 220 | # overrides: # List of overrides for the matchmaking specifications above. When a challenge is created, either the default specification above or one of the overrides will be randomly chosen. 221 | # bullet_only_horde: # Name of the override. Can be anything as long as each override has a unique name ("bullet_only_horde" and "easy_chess960" in these examples). 222 | # challenge_variant: "horde" # List of options to override. Only the options mentioned will change when making the challenge. The rest will follow the default matchmaking options above. 223 | # challenge_initial_time: 224 | # - 1 225 | # - 2 226 | # challenge_increment: 227 | # - 0 228 | # - 1 229 | # 230 | # easy_chess960: 231 | # challenge_variant: "chess960" 232 | # opponent_min_rating: 400 233 | # opponent_max_rating: 1200 234 | # opponent_rating_difference: 235 | # challenge_mode: casual 236 | # 237 | # no_pressure_correspondence: 238 | # challenge_initial_time: 239 | # challenge_increment: 240 | # challenge_days: 241 | # - 2 242 | # - 3 243 | # challenge_mode: casual 244 | # 245 | # The following configurations cannot be overridden: allow_matchmaking, challenge_timeout, challenge_filter and block_list. 246 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VARIANT 2 | FROM python:3$VARIANT 3 | LABEL org.opencontainers.image.authors="Lichess Bot Devs " 4 | LABEL org.opencontainers.image.description="A bridge between Lichess API and chess engines" 5 | LABEL org.opencontainers.image.source="https://github.com/lichess-bot-devs/lichess-bot" 6 | LABEL org.opencontainers.image.documentation="https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-use-the-Docker-image" 7 | LABEL org.opencontainers.image.title="lichess-bot" 8 | LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later" 9 | 10 | ENV LICHESS_BOT_DOCKER="true" 11 | ENV PYTHONDONTWRITEBYTECODE=1 12 | 13 | ARG LICHESS_DIR=/lichess-bot 14 | WORKDIR $LICHESS_DIR 15 | 16 | COPY . . 17 | 18 | RUN python3 -m pip install --no-cache-dir -r requirements.txt 19 | 20 | RUN chmod +x docker/copy_files.sh && ln -s $LICHESS_DIR/config/config.yml config.yml 21 | 22 | CMD docker/copy_files.sh && python3 lichess-bot.py ${OPTIONS} --disable_auto_logging 23 | -------------------------------------------------------------------------------- /docker/Dockerfile.dockerignore: -------------------------------------------------------------------------------- 1 | docker/* 2 | !docker/copy_files.sh 3 | docs/ 4 | wiki/ 5 | .git* 6 | **/__pycache__ 7 | -------------------------------------------------------------------------------- /docker/copy_files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -e /lichess-bot/config/homemade.py ]; then 4 | ln -sf /lichess-bot/config/homemade.py /lichess-bot/ 5 | fi 6 | 7 | if [ -e /lichess-bot/config/extra_game_handlers.py ]; then 8 | ln -sf /lichess-bot/config/extra_game_handlers.py /lichess-bot/ 9 | fi 10 | -------------------------------------------------------------------------------- /docs/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Our Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Enforcement 52 | 53 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 54 | reported to the community leaders responsible for enforcement 55 | at lichess.bot.devs@gmail.com. 56 | All complaints will be reviewed and investigated promptly and fairly. 57 | 58 | All community leaders are obligated to respect the privacy and security of the 59 | reporter of any incident. 60 | 61 | ## Attribution 62 | 63 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 64 | 65 | For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq 66 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to lichess-bot 2 | 3 | We welcome your contributions! There are multiple ways to contribute. 4 | 5 | ## Table of Contents 6 | 7 | 1. [Code of Conduct](#code-of-conduct) 8 | 2. [How to Contribute](#how-to-contribute) 9 | 3. [Reporting Bugs](#reporting-bugs) 10 | 4. [Requesting Features](#requesting-features) 11 | 5. [Submitting Pull Requests](#submitting-pull-requests) 12 | 6. [Testing](#testing) 13 | 7. [Documentation](#documentation) 14 | 15 | ## Code of Conduct 16 | 17 | Please review our [Code of Conduct](/docs/CODE_OF_CONDUCT.md) before participating in our community. We want all contributors to feel welcome and to foster an open and inclusive environment. 18 | 19 | ## How to Contribute 20 | 21 | We welcome contributions in the form of bug reports, feature requests, code changes, and documentation improvements. Here's how you can contribute: 22 | 23 | - Fork the repository to your GitHub account. 24 | - Create a new branch for your feature or bug fix. 25 | - Make your changes and commit them with a clear and concise commit message. 26 | - Push your changes to your branch. 27 | - Submit a pull request to the main repository. 28 | 29 | Please follow our [Pull Request Template](/.github/pull_request_template.md) when submitting a pull request. 30 | 31 | ## Reporting Bugs 32 | 33 | If you find a bug, please open an issue with a detailed description of the problem. Include information about your environment and steps to reproduce the issue. 34 | When filing a bug remember that the better written the bug is, the more likely it is to be fixed. 35 | Please follow our [Bug Report Template](/.github/ISSUE_TEMPLATE/bug_report.md) when submitting a pull request. 36 | 37 | ## Requesting Features 38 | 39 | We encourage you to open an issue to propose new features or improvements. Please provide as much detail as possible about your suggestion. 40 | Please follow our [Feature Request Template](/.github/ISSUE_TEMPLATE/feature_request.md) when submitting a pull request. 41 | 42 | ## Submitting Pull Requests 43 | 44 | When submitting a pull request, please ensure the following: 45 | 46 | - You have added or updated relevant documentation. 47 | - Tests (if applicable) have been added or updated. 48 | - Your branch is up-to-date with the main repository. 49 | - The pull request title and description are clear and concise. 50 | 51 | ## Testing 52 | 53 | Ensure that your changes pass all existing tests and consider adding new tests if applicable. 54 | 55 | ## Documentation 56 | 57 | Improvements to the documentation are always welcome. If you find areas that need clarification or additional information, please submit a pull request. 58 | -------------------------------------------------------------------------------- /docs/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Issues 2 | 3 | The lichess-bot team and community take security bugs in lichess-bot seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. 4 | 5 | To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/lichess-bot-devs/lichess-bot/security/advisories/new) tab. 6 | 7 | The lichess-bot team will send a response indicating the next steps in handling your report. After the initial reply to your report, the team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. 8 | 9 | Report security bugs in third-party modules to the person or team maintaining the module. 10 | -------------------------------------------------------------------------------- /engines/README.md: -------------------------------------------------------------------------------- 1 | Put your engines and opening books here. -------------------------------------------------------------------------------- /extra_game_handlers.py: -------------------------------------------------------------------------------- 1 | """Functions for the user to implement when the config file is not adequate to express bot requirements.""" 2 | from lib import model 3 | from lib.lichess_types import OPTIONS_TYPE 4 | 5 | 6 | def game_specific_options(game: model.Game) -> OPTIONS_TYPE: # noqa: ARG001 7 | """ 8 | Return a dictionary of engine options based on game aspects. 9 | 10 | By default, an empty dict is returned so that the options in the configuration file are used. 11 | """ 12 | return {} 13 | 14 | 15 | def is_supported_extra(challenge: model.Challenge) -> bool: # noqa: ARG001 16 | """ 17 | Determine whether to accept a challenge. 18 | 19 | By default, True is always returned so that there are no extra restrictions beyond those in the config file. 20 | """ 21 | return True 22 | -------------------------------------------------------------------------------- /homemade.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some example classes for people who want to create a homemade bot. 3 | 4 | With these classes, bot makers will not have to implement the UCI or XBoard interfaces themselves. 5 | """ 6 | import chess 7 | from chess.engine import PlayResult, Limit 8 | import random 9 | from lib.engine_wrapper import MinimalEngine 10 | from lib.lichess_types import MOVE, HOMEMADE_ARGS_TYPE 11 | import logging 12 | 13 | 14 | # Use this logger variable to print messages to the console or log files. 15 | # logger.info("message") will always print "message" to the console or log file. 16 | # logger.debug("message") will only print "message" if verbose logging is enabled. 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class ExampleEngine(MinimalEngine): 21 | """An example engine that all homemade engines inherit.""" 22 | 23 | 24 | # Bot names and ideas from tom7's excellent eloWorld video 25 | 26 | class RandomMove(ExampleEngine): 27 | """Get a random move.""" 28 | 29 | def search(self, board: chess.Board, *args: HOMEMADE_ARGS_TYPE) -> PlayResult: # noqa: ARG002 30 | """Choose a random move.""" 31 | return PlayResult(random.choice(list(board.legal_moves)), None) 32 | 33 | 34 | class Alphabetical(ExampleEngine): 35 | """Get the first move when sorted by san representation.""" 36 | 37 | def search(self, board: chess.Board, *args: HOMEMADE_ARGS_TYPE) -> PlayResult: # noqa: ARG002 38 | """Choose the first move alphabetically.""" 39 | moves = list(board.legal_moves) 40 | moves.sort(key=board.san) 41 | return PlayResult(moves[0], None) 42 | 43 | 44 | class FirstMove(ExampleEngine): 45 | """Get the first move when sorted by uci representation.""" 46 | 47 | def search(self, board: chess.Board, *args: HOMEMADE_ARGS_TYPE) -> PlayResult: # noqa: ARG002 48 | """Choose the first move alphabetically in uci representation.""" 49 | moves = list(board.legal_moves) 50 | moves.sort(key=str) 51 | return PlayResult(moves[0], None) 52 | 53 | 54 | class ComboEngine(ExampleEngine): 55 | """ 56 | Get a move using multiple different methods. 57 | 58 | This engine demonstrates how one can use `time_limit`, `draw_offered`, and `root_moves`. 59 | """ 60 | 61 | def search(self, 62 | board: chess.Board, 63 | time_limit: Limit, 64 | ponder: bool, # noqa: ARG002 65 | draw_offered: bool, 66 | root_moves: MOVE) -> PlayResult: 67 | """ 68 | Choose a move using multiple different methods. 69 | 70 | :param board: The current position. 71 | :param time_limit: Conditions for how long the engine can search (e.g. we have 10 seconds and search up to depth 10). 72 | :param ponder: Whether the engine can ponder after playing a move. 73 | :param draw_offered: Whether the bot was offered a draw. 74 | :param root_moves: If it is a list, the engine should only play a move that is in `root_moves`. 75 | :return: The move to play. 76 | """ 77 | if isinstance(time_limit.time, int): 78 | my_time = time_limit.time 79 | my_inc = 0 80 | elif board.turn == chess.WHITE: 81 | my_time = time_limit.white_clock if isinstance(time_limit.white_clock, int) else 0 82 | my_inc = time_limit.white_inc if isinstance(time_limit.white_inc, int) else 0 83 | else: 84 | my_time = time_limit.black_clock if isinstance(time_limit.black_clock, int) else 0 85 | my_inc = time_limit.black_inc if isinstance(time_limit.black_inc, int) else 0 86 | 87 | possible_moves = root_moves if isinstance(root_moves, list) else list(board.legal_moves) 88 | 89 | if my_time / 60 + my_inc > 10: 90 | # Choose a random move. 91 | move = random.choice(possible_moves) 92 | else: 93 | # Choose the first move alphabetically in uci representation. 94 | possible_moves.sort(key=str) 95 | move = possible_moves[0] 96 | return PlayResult(move, None, draw_offered=draw_offered) 97 | -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- 1 | """This lib folder contains the library code necessary for running lichess-bot.""" 2 | -------------------------------------------------------------------------------- /lib/config.py: -------------------------------------------------------------------------------- 1 | """Code related to the config that lichess-bot uses.""" 2 | from __future__ import annotations 3 | import yaml 4 | import os 5 | import logging 6 | import math 7 | import requests 8 | from abc import ABCMeta 9 | from typing import Any, Union, ItemsView, Callable 10 | from lib.lichess_types import CONFIG_DICT_TYPE, FilterType 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Configuration: 16 | """The config or a sub-config that the bot uses.""" 17 | 18 | def __init__(self, parameters: CONFIG_DICT_TYPE) -> None: 19 | """:param parameters: A `dict` containing the config for the bot.""" 20 | self.config = parameters 21 | 22 | def __getattr__(self, name: str) -> Any: 23 | """ 24 | Enable the use of `config.key1.key2`. 25 | 26 | :param name: The key to get its value. 27 | :return: The value of the key. 28 | """ 29 | return self.lookup(name) 30 | 31 | def lookup(self, name: str) -> Any: 32 | """ 33 | Get the value of a key. 34 | 35 | :param name: The key to get its value. 36 | :return: `Configuration` if the value is a `dict` else returns the value. 37 | """ 38 | data = self.config.get(name) 39 | return Configuration(data) if isinstance(data, dict) else data 40 | 41 | def items(self) -> ItemsView[str, Any]: 42 | """:return: All the key-value pairs in this config.""" 43 | return self.config.items() 44 | 45 | def keys(self) -> list[str]: 46 | """:return: All of the keys in this config.""" 47 | return list(self.config.keys()) 48 | 49 | def __or__(self, other: Union[Configuration, CONFIG_DICT_TYPE]) -> Configuration: 50 | """Create a copy of this configuration that is updated with values from the parameter.""" 51 | other_dict = other.config if isinstance(other, Configuration) else other 52 | return Configuration(self.config | other_dict) 53 | 54 | def __bool__(self) -> bool: 55 | """Whether `self.config` is empty.""" 56 | return bool(self.config) 57 | 58 | def __getstate__(self) -> CONFIG_DICT_TYPE: 59 | """Get `self.config`.""" 60 | return self.config 61 | 62 | def __setstate__(self, d: CONFIG_DICT_TYPE) -> None: 63 | """Set `self.config`.""" 64 | self.config = d 65 | 66 | 67 | def config_assert(assertion: bool, error_message: str) -> None: 68 | """Raise an exception if an assertion is false.""" 69 | if not assertion: 70 | raise Exception(error_message) 71 | 72 | 73 | def config_warn(assertion: bool, warning_message: str) -> None: 74 | """Print a warning message if an assertion is false.""" 75 | if not assertion: 76 | logger.warning(warning_message) 77 | 78 | 79 | def check_config_section(config: CONFIG_DICT_TYPE, data_name: str, data_type: ABCMeta, subsection: str = "") -> None: 80 | """ 81 | Check the validity of a config section. 82 | 83 | :param config: The config section. 84 | :param data_name: The key to check its value. 85 | :param data_type: The expected data type. 86 | :param subsection: The subsection of the key. 87 | """ 88 | config_part = config[subsection] if subsection else config 89 | sub = f"`{subsection}` sub" if subsection else "" 90 | data_location = f"`{data_name}` subsection in `{subsection}`" if subsection else f"Section `{data_name}`" 91 | type_error_message = {str: f"{data_location} must be a string wrapped in quotes.", 92 | dict: f"{data_location} must be a dictionary with indented keys followed by colons."} 93 | config_assert(data_name in config_part, f"Your config.yml does not have required {sub}section `{data_name}`.") 94 | config_assert(isinstance(config_part[data_name], data_type), type_error_message[data_type]) 95 | 96 | 97 | def set_config_default(config: CONFIG_DICT_TYPE, *sections: str, key: str, default: Any, 98 | force_empty_values: bool = False) -> CONFIG_DICT_TYPE: 99 | """ 100 | Fill a specific config key with the default value if it is missing. 101 | 102 | :param config: The bot's config. 103 | :param sections: The sections that the key is in. 104 | :param key: The key to set. 105 | :param default: The default value. 106 | :param force_empty_values: Whether an empty value should be replaced with the default value. 107 | :return: The new config with the default value inserted if needed. 108 | """ 109 | subconfig = config 110 | for section in sections: 111 | subconfig = subconfig.setdefault(section, {}) 112 | if not isinstance(subconfig, dict): 113 | raise Exception(f"The {section} section in {sections} should hold a set of key-value pairs, not a value.") 114 | if force_empty_values: 115 | if subconfig.get(key) in [None, ""]: 116 | subconfig[key] = default 117 | else: 118 | subconfig.setdefault(key, default) 119 | return subconfig 120 | 121 | 122 | def change_value_to_list(config: CONFIG_DICT_TYPE, *sections: str, key: str) -> None: 123 | """ 124 | Change a single value to a list. e.g. 60 becomes [60]. Used to maintain backwards compatibility. 125 | 126 | :param config: The bot's config. 127 | :param sections: The sections that the key is in. 128 | :param key: The key to set. 129 | """ 130 | subconfig = set_config_default(config, *sections, key=key, default=[]) 131 | 132 | if subconfig[key] is None: 133 | subconfig[key] = [] 134 | 135 | if not isinstance(subconfig[key], list): 136 | subconfig[key] = [subconfig[key]] 137 | 138 | 139 | def insert_default_values(CONFIG: CONFIG_DICT_TYPE) -> None: 140 | """ 141 | Insert the default values of most keys to the config if they are missing. 142 | 143 | :param CONFIG: The bot's config. 144 | """ 145 | set_config_default(CONFIG, key="abort_time", default=20) 146 | set_config_default(CONFIG, key="move_overhead", default=1000) 147 | set_config_default(CONFIG, key="quit_after_all_games_finish", default=False) 148 | set_config_default(CONFIG, key="rate_limiting_delay", default=0) 149 | set_config_default(CONFIG, key="pgn_directory", default=None) 150 | set_config_default(CONFIG, key="pgn_file_grouping", default="game", force_empty_values=True) 151 | set_config_default(CONFIG, key="max_takebacks_accepted", default=0, force_empty_values=True) 152 | set_config_default(CONFIG, "engine", key="interpreter", default=None) 153 | set_config_default(CONFIG, "engine", key="interpreter_options", default=[], force_empty_values=True) 154 | change_value_to_list(CONFIG, "engine", key="interpreter_options") 155 | set_config_default(CONFIG, "engine", key="working_dir", default=os.getcwd(), force_empty_values=True) 156 | set_config_default(CONFIG, "engine", key="silence_stderr", default=False) 157 | set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_enabled", default=False) 158 | set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_for_egtb_zero", default=True) 159 | set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_enabled", default=False) 160 | set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_for_egtb_minus_two", default=True) 161 | set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_moves", default=3) 162 | set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_score", default=-1000) 163 | set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_moves", default=5) 164 | set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_score", default=0) 165 | set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_pieces", default=10) 166 | set_config_default(CONFIG, "engine", "online_moves", key="max_out_of_book_moves", default=10) 167 | set_config_default(CONFIG, "engine", "online_moves", key="max_retries", default=2, force_empty_values=True) 168 | set_config_default(CONFIG, "engine", "online_moves", key="max_depth", default=math.inf, force_empty_values=True) 169 | set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="enabled", default=False) 170 | set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="source", default="lichess") 171 | set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="min_time", default=20) 172 | set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="max_pieces", default=7) 173 | set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="move_quality", default="best") 174 | set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="enabled", default=False) 175 | set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_time", default=20) 176 | set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="move_quality", default="good") 177 | set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_depth", default=20) 178 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="enabled", default=False) 179 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_time", default=20) 180 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="move_quality", default="best") 181 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_depth", default=20) 182 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_knodes", default=0) 183 | set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="max_score_difference", default=50) 184 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="enabled", default=False) 185 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_time", default=20) 186 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="source", default="masters") 187 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="player_name", default="") 188 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="sort", default="winrate") 189 | set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_games", default=10) 190 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="enabled", default=False) 191 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="max_pieces", default=7) 192 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="move_quality", default="best") 193 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="enabled", default=False) 194 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="max_pieces", default=5) 195 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="move_quality", default="best") 196 | set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="min_dtm_to_consider_as_wdl_1", default=120) 197 | set_config_default(CONFIG, "engine", "polyglot", key="enabled", default=False) 198 | set_config_default(CONFIG, "engine", "polyglot", key="max_depth", default=8) 199 | set_config_default(CONFIG, "engine", "polyglot", key="selection", default="weighted_random") 200 | set_config_default(CONFIG, "engine", "polyglot", key="min_weight", default=1) 201 | set_config_default(CONFIG, "engine", "polyglot", key="normalization", default="none") 202 | set_config_default(CONFIG, "challenge", key="concurrency", default=1) 203 | set_config_default(CONFIG, "challenge", key="sort_by", default="best") 204 | set_config_default(CONFIG, "challenge", key="preference", default="none") 205 | set_config_default(CONFIG, "challenge", key="accept_bot", default=False) 206 | set_config_default(CONFIG, "challenge", key="only_bot", default=False) 207 | set_config_default(CONFIG, "challenge", key="max_increment", default=180) 208 | set_config_default(CONFIG, "challenge", key="min_increment", default=0) 209 | set_config_default(CONFIG, "challenge", key="max_base", default=math.inf) 210 | set_config_default(CONFIG, "challenge", key="min_base", default=0) 211 | set_config_default(CONFIG, "challenge", key="max_days", default=math.inf) 212 | set_config_default(CONFIG, "challenge", key="min_days", default=1) 213 | set_config_default(CONFIG, "challenge", key="block_list", default=[], force_empty_values=True) 214 | set_config_default(CONFIG, "challenge", key="online_block_list", default=[], force_empty_values=True) 215 | set_config_default(CONFIG, "challenge", key="allow_list", default=[], force_empty_values=True) 216 | set_config_default(CONFIG, "challenge", key="max_simultaneous_games_per_user", default=5) 217 | set_config_default(CONFIG, "correspondence", key="checkin_period", default=600) 218 | set_config_default(CONFIG, "correspondence", key="move_time", default=60, force_empty_values=True) 219 | set_config_default(CONFIG, "correspondence", key="disconnect_time", default=300) 220 | set_config_default(CONFIG, "matchmaking", key="challenge_timeout", default=30, force_empty_values=True) 221 | CONFIG["matchmaking"]["challenge_timeout"] = max(CONFIG["matchmaking"]["challenge_timeout"], 1) 222 | set_config_default(CONFIG, "matchmaking", key="block_list", default=[], force_empty_values=True) 223 | set_config_default(CONFIG, "matchmaking", key="online_block_list", default=[], force_empty_values=True) 224 | set_config_default(CONFIG, "matchmaking", key="include_challenge_block_list", default=False, force_empty_values=True) 225 | default_filter = (CONFIG.get("matchmaking") or {}).get("delay_after_decline") or FilterType.NONE.value 226 | set_config_default(CONFIG, "matchmaking", key="challenge_filter", default=default_filter, force_empty_values=True) 227 | set_config_default(CONFIG, "matchmaking", key="allow_matchmaking", default=False) 228 | set_config_default(CONFIG, "matchmaking", key="challenge_initial_time", default=[None], force_empty_values=True) 229 | change_value_to_list(CONFIG, "matchmaking", key="challenge_initial_time") 230 | set_config_default(CONFIG, "matchmaking", key="challenge_increment", default=[None], force_empty_values=True) 231 | change_value_to_list(CONFIG, "matchmaking", key="challenge_increment") 232 | set_config_default(CONFIG, "matchmaking", key="challenge_days", default=[None], force_empty_values=True) 233 | change_value_to_list(CONFIG, "matchmaking", key="challenge_days") 234 | set_config_default(CONFIG, "matchmaking", key="opponent_min_rating", default=600, force_empty_values=True) 235 | set_config_default(CONFIG, "matchmaking", key="opponent_max_rating", default=4000, force_empty_values=True) 236 | set_config_default(CONFIG, "matchmaking", key="rating_preference", default="none") 237 | set_config_default(CONFIG, "matchmaking", key="challenge_variant", default="random") 238 | set_config_default(CONFIG, "matchmaking", key="challenge_mode", default="random") 239 | set_config_default(CONFIG, "matchmaking", key="overrides", default={}, force_empty_values=True) 240 | for override_config in CONFIG["matchmaking"]["overrides"].values(): 241 | for parameter in ["challenge_initial_time", "challenge_increment", "challenge_days"]: 242 | if parameter in override_config: 243 | set_config_default(override_config, key=parameter, default=[None], force_empty_values=True) 244 | change_value_to_list(override_config, key=parameter) 245 | 246 | for section in ["engine", "correspondence"]: 247 | for ponder in ["ponder", "uci_ponder"]: 248 | set_config_default(CONFIG, section, key=ponder, default=False) 249 | 250 | for greeting in ["hello", "goodbye"]: 251 | for target in ["", "_spectators"]: 252 | set_config_default(CONFIG, "greeting", key=greeting + target, default="", force_empty_values=True) 253 | 254 | 255 | def process_block_list(CONFIG: CONFIG_DICT_TYPE) -> None: 256 | """ 257 | Retrieve online block lists and copy over challenge blocklist if necessary. 258 | 259 | :param CONFIG: The bot's config. 260 | """ 261 | def parse_block_list_from_url(url: str) -> list[str]: 262 | block_list = requests.get(url).text.strip() 263 | return [username.strip() for username in block_list.split("\n")] 264 | 265 | for url in CONFIG["matchmaking"]["online_block_list"]: 266 | CONFIG["matchmaking"]["block_list"].extend(parse_block_list_from_url(url)) 267 | 268 | for url in CONFIG["challenge"]["online_block_list"]: 269 | CONFIG["challenge"]["block_list"].extend(parse_block_list_from_url(url)) 270 | 271 | if CONFIG["matchmaking"]["include_challenge_block_list"]: 272 | CONFIG["matchmaking"]["block_list"].extend(CONFIG["challenge"]["block_list"]) 273 | 274 | 275 | def log_config(CONFIG: CONFIG_DICT_TYPE, alternate_log_function: Callable[[str], Any] | None = None) -> None: 276 | """ 277 | Log the config to make debugging easier. 278 | 279 | :param CONFIG: The bot's config. 280 | """ 281 | logger_config = CONFIG.copy() 282 | logger_config["token"] = "logger" # noqa: S105 (Possible hardcoded password) 283 | destination = alternate_log_function or logger.debug 284 | destination(f"Config:\n{yaml.dump(logger_config, sort_keys=False)}") 285 | destination("====================") 286 | 287 | 288 | def validate_config(CONFIG: CONFIG_DICT_TYPE) -> None: 289 | """Check if the config is valid.""" 290 | check_config_section(CONFIG, "token", str) 291 | check_config_section(CONFIG, "url", str) 292 | check_config_section(CONFIG, "engine", dict) 293 | check_config_section(CONFIG, "challenge", dict) 294 | check_config_section(CONFIG, "dir", str, "engine") 295 | check_config_section(CONFIG, "name", str, "engine") 296 | 297 | config_assert(os.path.isdir(CONFIG["engine"]["dir"]), 298 | f'Your engine directory `{CONFIG["engine"]["dir"]}` is not a directory.') 299 | 300 | working_dir = CONFIG["engine"].get("working_dir") 301 | config_assert(not working_dir or os.path.isdir(working_dir), 302 | f"Your engine's working directory `{working_dir}` is not a directory.") 303 | 304 | engine = os.path.join(CONFIG["engine"]["dir"], CONFIG["engine"]["name"]) 305 | config_assert(os.path.isfile(engine) or CONFIG["engine"]["protocol"] == "homemade", 306 | f"The engine {engine} file does not exist.") 307 | config_assert(os.access(engine, os.X_OK) or CONFIG["engine"]["protocol"] == "homemade", 308 | f"The engine {engine} doesn't have execute (x) permission. Try: chmod +x {engine}") 309 | 310 | if CONFIG["engine"]["protocol"] == "xboard": 311 | for section, subsection in (("online_moves", "online_egtb"), 312 | ("lichess_bot_tbs", "syzygy"), 313 | ("lichess_bot_tbs", "gaviota")): 314 | online_section = (CONFIG["engine"].get(section) or {}).get(subsection) or {} 315 | config_assert(online_section.get("move_quality") != "suggest" or not online_section.get("enabled"), 316 | f"XBoard engines can't be used with `move_quality` set to `suggest` in {subsection}.") 317 | 318 | config_warn(CONFIG["challenge"]["concurrency"] > 0, "With challenge.concurrency set to 0, the bot won't accept or create " 319 | "any challenges.") 320 | 321 | config_assert(CONFIG["challenge"]["sort_by"] in ["best", "first"], "challenge.sort_by can be either `first` or `best`.") 322 | config_assert(CONFIG["challenge"]["preference"] in ["none", "human", "bot"], 323 | "challenge.preference should be `none`, `human`, or `bot`.") 324 | 325 | min_max_template = ("challenge.max_{setting} < challenge.min_{setting} will result " 326 | "in no {game_type} challenges being accepted.") 327 | for setting in ["increment", "base", "days"]: 328 | game_type = "correspondence" if setting == "days" else "real-time" 329 | config_warn(CONFIG["challenge"][f"min_{setting}"] <= CONFIG["challenge"][f"max_{setting}"], 330 | min_max_template.format(setting=setting, game_type=game_type)) 331 | 332 | matchmaking = CONFIG["matchmaking"] 333 | matchmaking_enabled = matchmaking["allow_matchmaking"] 334 | 335 | if matchmaking_enabled: 336 | config_warn(matchmaking["opponent_min_rating"] <= matchmaking["opponent_max_rating"], 337 | "matchmaking.opponent_max_rating < matchmaking.opponent_min_rating will result in " 338 | "no challenges being created.") 339 | config_warn(matchmaking.get("opponent_rating_difference", 0) >= 0, 340 | "matchmaking.opponent_rating_difference < 0 will result in no challenges being created.") 341 | 342 | pgn_directory = CONFIG["pgn_directory"] 343 | in_docker = os.environ.get("LICHESS_BOT_DOCKER") 344 | config_warn(not pgn_directory or not in_docker, 345 | f"Games will be saved to '{pgn_directory}', please ensure this folder is in a mounted " 346 | "volume; Using the Docker's container internal file system will prevent " 347 | "you accessing the saved files and can lead to disk " 348 | "saturation.") 349 | 350 | valid_pgn_grouping_options = ["game", "opponent", "all"] 351 | config_pgn_choice = CONFIG["pgn_file_grouping"] 352 | config_assert(config_pgn_choice in valid_pgn_grouping_options, 353 | f"The `pgn_file_grouping` choice of `{config_pgn_choice}` is not valid. " 354 | f"Please choose from {valid_pgn_grouping_options}.") 355 | 356 | def has_valid_list(name: str) -> bool: 357 | entries = matchmaking.get(name) 358 | return isinstance(entries, list) and entries[0] is not None 359 | matchmaking_has_values = (has_valid_list("challenge_initial_time") 360 | and has_valid_list("challenge_increment") 361 | or has_valid_list("challenge_days")) 362 | config_assert(not matchmaking_enabled or matchmaking_has_values, 363 | "The time control to challenge other bots is not set. Either lists of challenge_initial_time and " 364 | "challenge_increment is required, or a list of challenge_days, or both.") 365 | 366 | filter_option = "challenge_filter" 367 | filter_type = matchmaking.get(filter_option) 368 | config_assert(filter_type is None or filter_type in FilterType.__members__.values(), 369 | f"{filter_type} is not a valid value for {filter_option} (formerly delay_after_decline) parameter. " 370 | f"Choices are: {', '.join(FilterType)}.") 371 | 372 | config_assert(matchmaking.get("rating_preference") in ["none", "high", "low"], 373 | f"{matchmaking.get('rating_preference')} is not a valid `matchmaking:rating_preference` option. " 374 | f"Valid options are 'none', 'high', or 'low'.") 375 | 376 | selection_choices = {"polyglot": ["weighted_random", "uniform_random", "best_move"], 377 | "chessdb_book": ["all", "good", "best"], 378 | "lichess_cloud_analysis": ["good", "best"], 379 | "online_egtb": ["best", "suggest"]} 380 | for db_name, valid_selections in selection_choices.items(): 381 | is_online = db_name != "polyglot" 382 | db_section = (CONFIG["engine"].get("online_moves") or {}) if is_online else CONFIG["engine"] 383 | db_config = db_section.get(db_name) or {} 384 | select_key = "selection" if db_name == "polyglot" else "move_quality" 385 | selection = db_config.get(select_key) 386 | select = f"{'online_moves:' if is_online else ''}{db_name}:{select_key}" 387 | config_assert(selection in valid_selections, 388 | f"`{selection}` is not a valid `engine:{select}` value. " 389 | f"Please choose from {valid_selections}.") 390 | 391 | polyglot_section = CONFIG["engine"].get("polyglot") or {} 392 | config_assert(polyglot_section.get("normalization") in ["none", "max", "sum"], 393 | f"`{polyglot_section.get('normalization')}` is not a valid choice for " 394 | f"`engine:polyglot:normalization`. Please choose from ['none', 'max', 'sum'].") 395 | 396 | lichess_tbs_config = CONFIG["engine"].get("lichess_bot_tbs") or {} 397 | quality_selections = ["best", "suggest"] 398 | for tb in ["syzygy", "gaviota"]: 399 | selection = (lichess_tbs_config.get(tb) or {}).get("move_quality") 400 | config_assert(selection in quality_selections, 401 | f"`{selection}` is not a valid choice for `engine:lichess_bot_tbs:{tb}:move_quality`. " 402 | f"Please choose from {quality_selections}.") 403 | 404 | explorer_choices = {"source": ["lichess", "masters", "player"], 405 | "sort": ["winrate", "games_played"]} 406 | explorer_config = (CONFIG["engine"].get("online_moves") or {}).get("lichess_opening_explorer") 407 | if explorer_config: 408 | for parameter, choice_list in explorer_choices.items(): 409 | explorer_choice = explorer_config.get(parameter) 410 | config_assert(explorer_choice in choice_list, 411 | f"`{explorer_choice}` is not a valid" 412 | f" `engine:online_moves:lichess_opening_explorer:{parameter}`" 413 | f" value. Please choose from {choice_list}.") 414 | 415 | 416 | def load_config(config_file: str) -> Configuration: 417 | """ 418 | Read the config. 419 | 420 | :param config_file: The filename of the config (usually `config.yml`). 421 | :return: A `Configuration` object containing the config. 422 | """ 423 | with open(config_file) as stream: 424 | try: 425 | CONFIG = yaml.safe_load(stream) 426 | except Exception: 427 | logger.exception("There appears to be a syntax problem with your config.yml") 428 | raise 429 | 430 | log_config(CONFIG) 431 | 432 | if "LICHESS_BOT_TOKEN" in os.environ: 433 | CONFIG["token"] = os.environ["LICHESS_BOT_TOKEN"] 434 | 435 | insert_default_values(CONFIG) 436 | process_block_list(CONFIG) 437 | log_config(CONFIG) 438 | validate_config(CONFIG) 439 | 440 | return Configuration(CONFIG) 441 | -------------------------------------------------------------------------------- /lib/conversation.py: -------------------------------------------------------------------------------- 1 | """Allows lichess-bot to send messages to the chat.""" 2 | import logging 3 | from lib import model 4 | from lib.engine_wrapper import EngineWrapper 5 | from lib.lichess import Lichess 6 | from lib.lichess_types import GameEventType 7 | from collections.abc import Sequence 8 | from lib.timer import seconds 9 | MULTIPROCESSING_LIST_TYPE = Sequence[model.Challenge] 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class ChatLine: 15 | """Information about the message.""" 16 | 17 | def __init__(self, message_info: GameEventType) -> None: 18 | """Information about the message.""" 19 | self.room = message_info["room"] 20 | """Whether the message was sent in the chat room or in the spectator room.""" 21 | self.username = message_info["username"] 22 | """The username of the account that sent the message.""" 23 | self.text = message_info["text"] 24 | """The message sent.""" 25 | 26 | 27 | class Conversation: 28 | """Enables the bot to communicate with its opponent and the spectators.""" 29 | 30 | def __init__(self, game: model.Game, engine: EngineWrapper, li: Lichess, version: str, 31 | challenge_queue: MULTIPROCESSING_LIST_TYPE) -> None: 32 | """ 33 | Communication between lichess-bot and the game chats. 34 | 35 | :param game: The game that the bot will send messages to. 36 | :param engine: The engine playing the game. 37 | :param li: A class that is used for communication with lichess. 38 | :param version: The lichess-bot version. 39 | :param challenge_queue: The active challenges the bot has. 40 | """ 41 | self.game = game 42 | self.engine = engine 43 | self.li = li 44 | self.version = version 45 | self.challengers = challenge_queue 46 | self.messages: list[ChatLine] = [] 47 | 48 | command_prefix = "!" 49 | 50 | def react(self, line: ChatLine) -> None: 51 | """ 52 | React to a received message. 53 | 54 | :param line: Information about the message. 55 | """ 56 | self.messages.append(line) 57 | logger.info(f"*** {self.game.url()} [{line.room}] {line.username}: {line.text}") 58 | if line.text[0] == self.command_prefix: 59 | self.command(line, line.text[1:].lower()) 60 | 61 | def command(self, line: ChatLine, cmd: str) -> None: 62 | """ 63 | Reacts to the specific commands in the chat. 64 | 65 | :param line: Information about the message. 66 | :param cmd: The command to react to. 67 | """ 68 | from_self = line.username == self.game.username 69 | is_eval = cmd.startswith("eval") 70 | if cmd in ("commands", "help"): 71 | self.send_reply(line, 72 | "Supported commands: !wait (wait a minute for my first move), !name, " 73 | "!eval (or any text starting with !eval), !queue") 74 | elif cmd == "wait" and self.game.is_abortable(): 75 | self.game.ping(seconds(60), seconds(120), seconds(120)) 76 | self.send_reply(line, "Waiting 60 seconds...") 77 | elif cmd == "name": 78 | name = self.game.me.name 79 | self.send_reply(line, f"{name} running {self.engine.name()} (lichess-bot v{self.version})") 80 | elif is_eval and (from_self or line.room == "spectator"): 81 | stats = self.engine.get_stats(for_chat=True) 82 | self.send_reply(line, ", ".join(stats)) 83 | elif is_eval: 84 | self.send_reply(line, "I don't tell that to my opponent, sorry.") 85 | elif cmd == "queue": 86 | if self.challengers: 87 | challengers = ", ".join([f"@{challenger.challenger.name}" for challenger in reversed(self.challengers)]) 88 | self.send_reply(line, f"Challenge queue: {challengers}") 89 | else: 90 | self.send_reply(line, "No challenges queued.") 91 | 92 | def send_reply(self, line: ChatLine, reply: str) -> None: 93 | """ 94 | Send the reply to the chat. 95 | 96 | :param line: Information about the original message that we reply to. 97 | :param reply: The reply to send. 98 | """ 99 | logger.info(f"*** {self.game.url()} [{line.room}] {self.game.username}: {reply}") 100 | self.li.chat(self.game.id, line.room, reply) 101 | 102 | def send_message(self, room: str, message: str) -> None: 103 | """Send the message to the chat.""" 104 | if message: 105 | self.send_reply(ChatLine({"room": room, "username": "", "text": ""}), message) 106 | -------------------------------------------------------------------------------- /lib/lichess.py: -------------------------------------------------------------------------------- 1 | """Communication with APIs.""" 2 | import json 3 | import requests 4 | from urllib.parse import urljoin 5 | from requests.exceptions import ConnectionError as RequestsConnectionError, HTTPError, ReadTimeout 6 | from http.client import RemoteDisconnected 7 | import backoff 8 | import logging 9 | import traceback 10 | from collections import defaultdict 11 | import datetime 12 | import contextlib 13 | from lib.timer import Timer, seconds, sec_str 14 | from typing import Optional, Union, cast 15 | import chess.engine 16 | from lib.lichess_types import (UserProfileType, REQUESTS_PAYLOAD_TYPE, GameType, PublicDataType, OnlineType, 17 | ChallengeType, TOKEN_TESTS_TYPE, BackoffDetails) 18 | 19 | 20 | ENDPOINTS = { 21 | "profile": "/api/account", 22 | "playing": "/api/account/playing", 23 | "stream": "/api/bot/game/stream/{}", 24 | "stream_event": "/api/stream/event", 25 | "move": "/api/bot/game/{}/move/{}", 26 | "takeback": "/api/bot/game/{}/takeback/{}", 27 | "chat": "/api/bot/game/{}/chat", 28 | "abort": "/api/bot/game/{}/abort", 29 | "accept": "/api/challenge/{}/accept", 30 | "decline": "/api/challenge/{}/decline", 31 | "upgrade": "/api/bot/account/upgrade", 32 | "resign": "/api/bot/game/{}/resign", 33 | "export": "/game/export/{}", 34 | "online_bots": "/api/bot/online", 35 | "challenge": "/api/challenge/{}", 36 | "cancel": "/api/challenge/{}/cancel", 37 | "status": "/api/users/status", 38 | "public_data": "/api/user/{}", 39 | "token_test": "/api/token/test" 40 | } 41 | 42 | 43 | logger = logging.getLogger(__name__) 44 | 45 | MAX_CHAT_MESSAGE_LEN = 140 # The maximum characters in a chat message. 46 | 47 | 48 | class Stop: 49 | """Class to stop the bot.""" 50 | 51 | def __init__(self) -> None: 52 | """Initialize the Stop class.""" 53 | self.terminated = False 54 | self.force_quit = False 55 | self.restart = True 56 | 57 | 58 | stop = Stop() 59 | 60 | 61 | class RateLimitedError(RuntimeError): 62 | """Exception raised when we are rate limited (status code 429).""" 63 | 64 | 65 | def is_new_rate_limit(response: requests.models.Response) -> bool: 66 | """Check if the status code is 429, which means that we are rate limited.""" 67 | return response.status_code == 429 68 | 69 | 70 | def is_final(exception: Exception) -> bool: 71 | """If `is_final` returns True then we won't retry.""" 72 | return (isinstance(exception, HTTPError) and exception.response is not None and exception.response.status_code < 500 73 | or stop.terminated or stop.force_quit) 74 | 75 | 76 | def backoff_handler(details: BackoffDetails) -> None: 77 | """Log exceptions inside functions with the backoff decorator.""" 78 | logger.debug("Backing off {wait:0.1f} seconds after {tries} tries " 79 | "calling function {target} with args {args} and kwargs {kwargs}".format(**details)) 80 | logger.debug(f"Exception: {traceback.format_exc()}") 81 | 82 | 83 | # Docs: https://lichess.org/api. 84 | class Lichess: 85 | """Communication with lichess.org (and chessdb.cn for getting moves).""" 86 | 87 | def __init__(self, token: str, url: str, version: str, logging_level: int, max_retries: int) -> None: 88 | """ 89 | Communication with lichess.org (and chessdb.cn for getting moves). 90 | 91 | :param token: The bot's token. 92 | :param url: The base url (lichess.org). 93 | :param version: The lichess-bot version running. 94 | :param logging_level: The logging level (logging.INFO or logging.DEBUG). 95 | :param max_retries: The maximum amount of retries for online moves (e.g. chessdb's opening book). 96 | """ 97 | self.version = version 98 | self.header = { 99 | "Authorization": f"Bearer {token}" 100 | } 101 | self.baseUrl = url 102 | self.session = requests.Session() 103 | self.session.headers.update(self.header) 104 | self.other_session = requests.Session() 105 | self.set_user_agent("?") 106 | self.logging_level = logging_level 107 | self.max_retries = max_retries 108 | self.rate_limit_timers: defaultdict[str, Timer] = defaultdict(Timer) 109 | 110 | # Confirm that the OAuth token has the proper permission to play on lichess 111 | token_response = cast(TOKEN_TESTS_TYPE, self.api_post("token_test", data=token)) 112 | token_info = token_response[token] 113 | 114 | if not token_info: 115 | raise RuntimeError("Token in config file is not recognized by lichess. " 116 | "Please check that it was copied correctly into your configuration file.") 117 | 118 | scopes = token_info["scopes"] 119 | if "bot:play" not in scopes.split(","): 120 | raise RuntimeError("Please use an API access token for your bot that " 121 | 'has the scope "Play games with the bot API (bot:play)". ' 122 | f"The current token has: {scopes}.") 123 | 124 | @backoff.on_exception(backoff.constant, 125 | (RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout), 126 | max_time=60, 127 | interval=0.1, 128 | giveup=is_final, 129 | on_backoff=backoff_handler, 130 | backoff_log_level=logging.DEBUG, 131 | giveup_log_level=logging.DEBUG) 132 | def api_get(self, endpoint_name: str, *template_args: str, 133 | params: Optional[dict[str, str]] = None, 134 | stream: bool = False, timeout: int = 2) -> requests.Response: 135 | """ 136 | Send a GET to lichess.org. 137 | 138 | :param endpoint_name: The name of the endpoint. 139 | :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). 140 | :param params: Parameters sent to lichess.org. 141 | :param stream: Whether the data returned from lichess.org should be streamed. 142 | :param timeout: The amount of time in seconds to wait for a response. 143 | :return: lichess.org's response. 144 | """ 145 | logging.getLogger("backoff").setLevel(self.logging_level) 146 | path_template = self.get_path_template(endpoint_name) 147 | url = urljoin(self.baseUrl, path_template.format(*template_args)) 148 | response = self.session.get(url, params=params, timeout=timeout, stream=stream) 149 | 150 | if is_new_rate_limit(response): 151 | delay = seconds(1 if endpoint_name == "move" else 60) 152 | self.set_rate_limit_delay(path_template, delay) 153 | 154 | response.raise_for_status() 155 | response.encoding = "utf-8" 156 | return response 157 | 158 | def api_get_json(self, endpoint_name: str, *template_args: str, 159 | params: Optional[dict[str, str]] = None 160 | ) -> Union[PublicDataType, UserProfileType, dict[str, list[GameType]]]: 161 | """ 162 | Send a GET to the lichess.org endpoints that return a JSON. 163 | 164 | :param endpoint_name: The name of the endpoint. 165 | :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). 166 | :param params: Parameters sent to lichess.org. 167 | :return: lichess.org's response in a dict. 168 | """ 169 | response = self.api_get(endpoint_name, *template_args, params=params) 170 | json_response: Union[PublicDataType, UserProfileType, dict[str, list[GameType]]] = response.json() 171 | return json_response 172 | 173 | def api_get_list(self, endpoint_name: str, *template_args: str, 174 | params: Optional[dict[str, str]] = None) -> list[UserProfileType]: 175 | """ 176 | Send a GET to the lichess.org endpoints that return a list containing JSON. 177 | 178 | :param endpoint_name: The name of the endpoint. 179 | :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). 180 | :param params: Parameters sent to lichess.org. 181 | :return: lichess.org's response in a list of dicts. 182 | """ 183 | response = self.api_get(endpoint_name, *template_args, params=params) 184 | json_response: list[UserProfileType] = response.json() 185 | return json_response 186 | 187 | def api_get_raw(self, endpoint_name: str, *template_args: str, 188 | params: Optional[dict[str, str]] = None) -> str: 189 | """ 190 | Send a GET to lichess.org that returns plain text (UTF-8). 191 | 192 | :param endpoint_name: The name of the endpoint. 193 | :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). 194 | :param params: Parameters sent to lichess.org. 195 | :return: The text of lichess.org's response. 196 | """ 197 | response = self.api_get(endpoint_name, *template_args, params=params) 198 | return response.text 199 | 200 | @backoff.on_exception(backoff.constant, 201 | (RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout), 202 | max_time=60, 203 | interval=0.1, 204 | giveup=is_final, 205 | on_backoff=backoff_handler, 206 | backoff_log_level=logging.DEBUG, 207 | giveup_log_level=logging.DEBUG) 208 | def api_post(self, 209 | endpoint_name: str, 210 | *template_args: str, 211 | data: Union[str, dict[str, str], None] = None, 212 | headers: Optional[dict[str, str]] = None, 213 | params: Optional[dict[str, str]] = None, 214 | payload: Optional[REQUESTS_PAYLOAD_TYPE] = None, 215 | raise_for_status: bool = True) -> Union[ChallengeType, Optional[TOKEN_TESTS_TYPE]]: 216 | """ 217 | Send a POST to lichess.org. 218 | 219 | :param endpoint_name: The name of the endpoint. 220 | :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`). 221 | :param data: Data sent to lichess.org. 222 | :param headers: The headers for the request. 223 | :param params: Parameters sent to lichess.org. 224 | :param payload: Payload sent to lichess.org. 225 | :param raise_for_status: Whether to raise an exception if the response contains an error code. 226 | :return: lichess.org's response in a dict. 227 | """ 228 | logging.getLogger("backoff").setLevel(self.logging_level) 229 | path_template = self.get_path_template(endpoint_name) 230 | url = urljoin(self.baseUrl, path_template.format(*template_args)) 231 | response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2) 232 | 233 | if is_new_rate_limit(response): 234 | self.set_rate_limit_delay(path_template, seconds(60)) 235 | 236 | if raise_for_status: 237 | response.raise_for_status() 238 | 239 | json_response: Union[ChallengeType, Optional[TOKEN_TESTS_TYPE]] = response.json() 240 | return json_response 241 | 242 | def get_path_template(self, endpoint_name: str) -> str: 243 | """ 244 | Get the path template given the endpoint name. Will raise an exception if the path template is rate limited. 245 | 246 | :param endpoint_name: The name of the endpoint. 247 | :return: The path template. 248 | """ 249 | path_template = ENDPOINTS[endpoint_name] 250 | if self.is_rate_limited(path_template): 251 | raise RateLimitedError(f"{path_template} is rate-limited. " 252 | f"Will retry in {sec_str(self.rate_limit_time_left(path_template))} seconds.") 253 | return path_template 254 | 255 | def set_rate_limit_delay(self, path_template: str, delay_time: datetime.timedelta) -> None: 256 | """ 257 | Set a delay to a path template if it was rate limited. 258 | 259 | :param path_template: The path template. 260 | :param delay_time: How long we won't call this endpoint. 261 | """ 262 | logger.warning(f"Endpoint {path_template} is rate limited. Waiting {sec_str(delay_time)} seconds until next request.") 263 | self.rate_limit_timers[path_template] = Timer(delay_time) 264 | 265 | def is_rate_limited(self, path_template: str) -> bool: 266 | """Check if a path template is rate limited.""" 267 | return not self.rate_limit_timers[path_template].is_expired() 268 | 269 | def rate_limit_time_left(self, path_template: str) -> datetime.timedelta: 270 | """How much time is left until we can use the path template normally.""" 271 | return self.rate_limit_timers[path_template].time_until_expiration() 272 | 273 | def upgrade_to_bot_account(self) -> None: 274 | """Upgrade the account to a BOT account.""" 275 | self.api_post("upgrade") 276 | 277 | def make_move(self, game_id: str, move: chess.engine.PlayResult) -> None: 278 | """ 279 | Make a move. 280 | 281 | :param game_id: The id of the game. 282 | :param move: The move to make. 283 | """ 284 | self.api_post("move", game_id, str(move.move), 285 | params={"offeringDraw": str(move.draw_offered).lower()}) 286 | 287 | def accept_takeback(self, game_id: str, accept: bool) -> bool: 288 | """Answer an opponent's move takeback request.""" 289 | try: 290 | self.api_post("takeback", game_id, "yes" if accept else "no") 291 | if accept: 292 | logger.info("Opponent took back previous move.") 293 | else: 294 | logger.info("Refused opponent's take back request.") 295 | return accept 296 | except Exception: 297 | return False 298 | 299 | def chat(self, game_id: str, room: str, text: str) -> None: 300 | """ 301 | Send a message to the chat. 302 | 303 | :param game_id: The id of the game. 304 | :param room: The room (either chat or spectator room). 305 | :param text: The text to send. 306 | """ 307 | if len(text) > MAX_CHAT_MESSAGE_LEN: 308 | logger.warning(f"This chat message is {len(text)} characters, which is longer " 309 | f"than the maximum of {MAX_CHAT_MESSAGE_LEN}. It will not be sent.") 310 | logger.warning(f"Message: {text}") 311 | 312 | data = {"room": room, "text": text} 313 | self.api_post("chat", game_id, data=data) 314 | 315 | def abort(self, game_id: str) -> None: 316 | """Aborts a game.""" 317 | self.api_post("abort", game_id) 318 | 319 | def get_event_stream(self) -> requests.models.Response: 320 | """Get a stream of the events (e.g. challenge, gameStart).""" 321 | return self.api_get("stream_event", stream=True, timeout=15) 322 | 323 | def get_game_stream(self, game_id: str) -> requests.models.Response: 324 | """Get stream of the in-game events (e.g. moves by the opponent).""" 325 | return self.api_get("stream", game_id, stream=True, timeout=15) 326 | 327 | def accept_challenge(self, challenge_id: str) -> None: 328 | """Accept a challenge.""" 329 | self.api_post("accept", challenge_id) 330 | 331 | def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None: 332 | """Decline a challenge.""" 333 | with contextlib.suppress(Exception): 334 | self.api_post("decline", challenge_id, 335 | data=f"reason={reason}", 336 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 337 | raise_for_status=False) 338 | 339 | def get_profile(self) -> UserProfileType: 340 | """Get the bot's profile (e.g. username).""" 341 | profile = cast(UserProfileType, self.api_get_json("profile")) 342 | self.set_user_agent(profile["username"]) 343 | return profile 344 | 345 | def get_ongoing_games(self) -> list[GameType]: 346 | """Get the bot's ongoing games.""" 347 | ongoing_games: list[GameType] = [] 348 | with contextlib.suppress(Exception): 349 | response = cast(dict[str, list[GameType]], self.api_get_json("playing")) 350 | ongoing_games = response["nowPlaying"] 351 | return ongoing_games 352 | 353 | def resign(self, game_id: str) -> None: 354 | """Resign a game.""" 355 | self.api_post("resign", game_id) 356 | 357 | def set_user_agent(self, username: str) -> None: 358 | """Set the user agent for communication with lichess.org.""" 359 | self.header.update({"User-Agent": f"lichess-bot/{self.version} user:{username}"}) 360 | self.session.headers.update(self.header) 361 | 362 | def get_game_pgn(self, game_id: str) -> str: 363 | """Get the PGN (Portable Game Notation) record of a game.""" 364 | try: 365 | return self.api_get_raw("export", game_id) 366 | except Exception: 367 | return "" 368 | 369 | def get_online_bots(self) -> list[UserProfileType]: 370 | """Get a list of bots that are online.""" 371 | try: 372 | online_bots_str = self.api_get_raw("online_bots") 373 | online_bots = list(filter(bool, online_bots_str.split("\n"))) 374 | return list(map(json.loads, online_bots)) 375 | except Exception: 376 | return [] 377 | 378 | def challenge(self, username: str, payload: REQUESTS_PAYLOAD_TYPE) -> ChallengeType: 379 | """Create a challenge.""" 380 | return cast(ChallengeType, 381 | self.api_post("challenge", username, payload=payload, raise_for_status=False)) 382 | 383 | def cancel(self, challenge_id: str) -> None: 384 | """Cancel a challenge.""" 385 | self.api_post("cancel", challenge_id, raise_for_status=False) 386 | 387 | def online_book_get(self, path: str, params: Optional[dict[str, Union[str, int]]] = None, 388 | stream: bool = False) -> OnlineType: 389 | """Get an external move from online sources (chessdb or lichess.org).""" 390 | @backoff.on_exception(backoff.constant, 391 | (RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout), 392 | max_time=60, 393 | max_tries=self.max_retries, 394 | interval=0.1, 395 | giveup=is_final, 396 | on_backoff=backoff_handler, 397 | backoff_log_level=logging.DEBUG, 398 | giveup_log_level=logging.DEBUG) 399 | def online_book_get() -> OnlineType: 400 | json_response: OnlineType = self.other_session.get(path, timeout=2, params=params, stream=stream).json() 401 | return json_response 402 | return online_book_get() 403 | 404 | def is_online(self, user_id: str) -> bool: 405 | """Check if lichess.org thinks the bot is online or not.""" 406 | user = self.api_get_list("status", params={"ids": user_id}) 407 | return bool(user and user[0].get("online")) 408 | 409 | def get_public_data(self, user_name: str) -> PublicDataType: 410 | """Get the public data of a bot.""" 411 | return cast(PublicDataType, self.api_get_json("public_data", user_name)) 412 | -------------------------------------------------------------------------------- /lib/lichess_types.py: -------------------------------------------------------------------------------- 1 | """Some type hints that can be accessed by all other python files.""" 2 | from typing import Any, Callable, Optional, Union, TypedDict, Literal 3 | from chess.engine import PovWdl, PovScore, PlayResult, Limit, Opponent 4 | from chess import Move, Board 5 | from queue import Queue 6 | import logging 7 | from enum import Enum 8 | from types import TracebackType 9 | 10 | COMMANDS_TYPE = list[str] 11 | MOVE = Union[PlayResult, list[Move]] 12 | CORRESPONDENCE_QUEUE_TYPE = Queue[str] 13 | LOGGING_QUEUE_TYPE = Queue[logging.LogRecord] 14 | REQUESTS_PAYLOAD_TYPE = dict[str, Union[str, int, bool]] 15 | GO_COMMANDS_TYPE = dict[str, str] 16 | EGTPATH_TYPE = dict[str, str] 17 | OPTIONS_GO_EGTB_TYPE = dict[str, Union[str, int, bool, None, EGTPATH_TYPE, GO_COMMANDS_TYPE]] 18 | OPTIONS_TYPE = dict[str, Union[str, int, bool, None]] 19 | HOMEMADE_ARGS_TYPE = Union[Limit, bool, MOVE] 20 | 21 | # Types that still use `Any`. 22 | CONFIG_DICT_TYPE = dict[str, Any] 23 | 24 | 25 | class PerfType(TypedDict, total=False): 26 | """Type hint for `perf`.""" 27 | 28 | games: int 29 | rating: int 30 | rd: int 31 | sd: int 32 | prov: bool 33 | prog: int 34 | 35 | 36 | class ProfileType(TypedDict, total=False): 37 | """Type hint for `profile`.""" 38 | 39 | country: str 40 | location: str 41 | bio: str 42 | firstName: str 43 | lastName: str 44 | fideRating: int 45 | uscfRating: int 46 | ecfRating: int 47 | cfcRating: int 48 | dsbRating: int 49 | links: str 50 | 51 | 52 | class UserProfileType(TypedDict, total=False): 53 | """Type hint for `user_profile`.""" 54 | 55 | id: str 56 | username: str 57 | perfs: dict[str, PerfType] 58 | createdAt: int 59 | profile: ProfileType 60 | seenAt: int 61 | patron: int 62 | verified: int 63 | playTime: dict[str, int] 64 | title: str 65 | online: bool 66 | url: str 67 | followable: bool 68 | following: bool 69 | blocking: bool 70 | followsYou: bool 71 | count: dict[str, int] 72 | 73 | 74 | class ReadableType(TypedDict): 75 | """Type hint for `readable`.""" 76 | 77 | Evaluation: Callable[[PovScore], str] 78 | Winrate: Callable[[PovWdl], str] 79 | Hashfull: Callable[[int], str] 80 | Nodes: Callable[[int], str] 81 | Speed: Callable[[int], str] 82 | Tbhits: Callable[[int], str] 83 | Cpuload: Callable[[int], str] 84 | Movetime: Callable[[int], str] 85 | 86 | 87 | class InfoStrDict(TypedDict, total=False): 88 | """Type hints for the readable version of the information returned by chess engines.""" 89 | 90 | score: PovScore 91 | pv: list[Move] 92 | depth: int 93 | seldepth: int 94 | time: float 95 | nodes: int 96 | nps: int 97 | tbhits: int 98 | multipv: int 99 | currmove: Union[str, Move] 100 | currmovenumber: int 101 | hashfull: int 102 | cpuload: int 103 | refutation: Union[str, dict[Move, list[Move]]] 104 | currline: dict[int, list[Move]] 105 | ebf: float 106 | wdl: PovWdl 107 | string: str 108 | ponderpv: str 109 | Source: str 110 | Pv: str 111 | 112 | 113 | InfoDictKeys = Literal["score", "pv", "depth", "seldepth", "time", "nodes", "nps", "tbhits", "multipv", "currmove", 114 | "currmovenumber", "hashfull", "cpuload", "refutation", "currline", "ebf", "wdl", "string", 115 | "ponderpv", "Source", "Pv"] 116 | 117 | 118 | InfoDictValue = Union[PovScore, list[Move], int, float, str, Move, dict[Move, list[Move]], dict[int, list[Move]], PovWdl] 119 | 120 | 121 | class PlayerType(TypedDict, total=False): 122 | """Type hint for information on a player.""" 123 | 124 | title: Optional[str] 125 | rating: int 126 | provisional: bool 127 | aiLevel: int 128 | id: str 129 | username: str 130 | name: str 131 | online: bool 132 | 133 | 134 | class GameType(TypedDict, total=False): 135 | """Type hint for game.""" 136 | 137 | gameId: str 138 | fullId: str 139 | color: str 140 | fen: str 141 | hasMoved: bool 142 | isMyTurn: bool 143 | lastMove: str 144 | opponent: PlayerType 145 | perf: str 146 | rated: bool 147 | secondsLeft: int 148 | source: str 149 | status: dict[str, Union[str, int]] 150 | speed: str 151 | variant: dict[str, str] 152 | compat: dict[str, bool] 153 | id: str 154 | winner: str 155 | ratingDiff: int 156 | pgn: str 157 | complete: bool 158 | 159 | 160 | class TimeControlType(TypedDict, total=False): 161 | """Type hint for time control.""" 162 | 163 | increment: int 164 | limit: int 165 | show: str 166 | type: str 167 | daysPerTurn: int 168 | initial: int 169 | 170 | 171 | class ChallengeType(TypedDict, total=False): 172 | """Type hint for challenge.""" 173 | 174 | id: str 175 | url: str 176 | color: str 177 | direction: str 178 | rated: bool 179 | speed: str 180 | status: str 181 | timeControl: TimeControlType 182 | variant: dict[str, str] 183 | challenger: PlayerType 184 | destUser: PlayerType 185 | perf: dict[str, str] 186 | compat: dict[str, bool] 187 | finalColor: str 188 | declineReason: str 189 | declineReasonKey: str 190 | initialFen: str 191 | 192 | 193 | class EventType(TypedDict, total=False): 194 | """Type hint for event.""" 195 | 196 | type: str 197 | game: GameType 198 | challenge: ChallengeType 199 | error: Optional[str] 200 | 201 | 202 | class GameStateType(TypedDict, total=False): 203 | """Type hint for game state.""" 204 | 205 | type: str 206 | moves: str 207 | wtime: int 208 | btime: int 209 | winc: int 210 | binc: int 211 | wdraw: bool 212 | bdraw: bool 213 | status: str 214 | winner: str 215 | 216 | 217 | class GameEventType(TypedDict, total=False): 218 | """Type hint for game event.""" 219 | 220 | type: str 221 | id: str 222 | rated: bool 223 | variant: dict[str, str] 224 | cloak: dict[str, int] 225 | speed: str 226 | perf: dict[str, str] 227 | createdAt: int 228 | white: PlayerType 229 | black: PlayerType 230 | initialFen: str 231 | state: GameStateType 232 | username: str 233 | text: str 234 | room: str 235 | gone: bool 236 | claimWinInSeconds: int 237 | moves: str 238 | wtime: int 239 | btime: int 240 | winc: int 241 | binc: int 242 | wdraw: bool 243 | bdraw: bool 244 | status: str 245 | winner: str 246 | clock: TimeControlType 247 | wtakeback: bool 248 | btakeback: bool 249 | 250 | 251 | CONTROL_QUEUE_TYPE = Queue[EventType] 252 | PGN_QUEUE_TYPE = Queue[Optional[EventType]] 253 | 254 | 255 | class PublicDataType(TypedDict, total=False): 256 | """Type hint for public data.""" 257 | 258 | id: str 259 | username: str 260 | perfs: dict[str, PerfType] 261 | flair: str 262 | createdAt: int 263 | disabled: bool 264 | tosViolation: bool 265 | profile: ProfileType 266 | seenAt: int 267 | patron: bool 268 | verified: bool 269 | playTime: dict[str, int] 270 | title: str 271 | url: str 272 | playing: str 273 | count: dict[str, int] 274 | streaming: bool 275 | streamer: dict[str, dict[str, str]] 276 | followable: bool 277 | following: bool 278 | blocking: bool 279 | followsYou: bool 280 | 281 | 282 | class FilterType(str, Enum): 283 | """What to do if the opponent declines our challenge.""" 284 | 285 | NONE = "none" 286 | """Will still challenge the opponent.""" 287 | COARSE = "coarse" 288 | """Won't challenge the opponent again.""" 289 | FINE = "fine" 290 | """ 291 | Won't challenge the opponent to a game of the same mode, speed, and variant 292 | based on the reason for the opponent declining the challenge. 293 | """ 294 | 295 | 296 | class ChessDBMoveType(TypedDict, total=False): 297 | """Type hint for a move returned by chessdb (opening & egtb).""" 298 | 299 | uci: str 300 | san: str 301 | score: int 302 | rank: int 303 | note: str 304 | winrate: str 305 | 306 | 307 | class LichessPvType(TypedDict, total=False): 308 | """Type hint for a move returned by lichess cloud analysis.""" 309 | 310 | moves: str 311 | cp: int 312 | 313 | 314 | class LichessExplorerGameType(TypedDict, total=False): 315 | """Type hint for a game returned by lichess explorer.""" 316 | 317 | id: str 318 | winner: Optional[str] 319 | speed: str 320 | mode: str 321 | black: dict[str, Union[str, int]] 322 | white: dict[str, Union[str, int]] 323 | year: int 324 | month: str 325 | 326 | 327 | class LichessEGTBMoveType(TypedDict): 328 | """Type hint for the moves returned by the lichess egtb.""" 329 | 330 | uci: str 331 | san: str 332 | zeroing: bool 333 | checkmate: bool 334 | stalemate: bool 335 | variant_win: bool 336 | variant_loss: bool 337 | insufficient_material: bool 338 | dtz: int 339 | precise_dtz: Optional[int] 340 | dtm: Optional[int] 341 | category: str 342 | 343 | 344 | class OnlineMoveType(TypedDict): 345 | """Type hint for a move returned by an online source.""" 346 | 347 | # chessdb 348 | uci: str 349 | san: str 350 | score: int 351 | rank: int 352 | note: str 353 | winrate: str 354 | 355 | # lichess explorer 356 | # uci: str (duplicate) 357 | # san: str (duplicate) 358 | averageRating: int 359 | performance: int 360 | white: int 361 | black: int 362 | draws: int 363 | game: Optional[LichessExplorerGameType] 364 | 365 | # lichess egtb 366 | # uci: str (duplicate) 367 | # san: str (duplicate) 368 | zeroing: bool 369 | checkmate: bool 370 | stalemate: bool 371 | variant_win: bool 372 | variant_loss: bool 373 | insufficient_material: bool 374 | dtz: int 375 | precise_dtz: Optional[int] 376 | dtm: Optional[int] 377 | category: str 378 | 379 | 380 | class OnlineType(TypedDict, total=False): 381 | """Type hint for moves returned by an online source.""" 382 | 383 | # lichess egtb 384 | checkmate: bool 385 | stalemate: bool 386 | variant_win: bool 387 | variant_loss: bool 388 | insufficient_material: bool 389 | dtz: int 390 | precise_dtz: Optional[int] 391 | dtm: Optional[int] 392 | category: str 393 | # moves: list[LichessEGTBMoveType] 394 | 395 | # lichess explorer 396 | white: int 397 | black: int 398 | draws: int 399 | # moves: list[LichessExplorerMoveType] 400 | topGames: list[LichessExplorerGameType] 401 | recentGames: list[LichessExplorerGameType] 402 | opening: Optional[dict[str, str]] 403 | queuePosition: int 404 | 405 | # lichess cloud 406 | fen: str 407 | knodes: int 408 | ply: str 409 | depth: int 410 | pvs: list[LichessPvType] 411 | error: str 412 | 413 | # chessdb (opening & egtb) 414 | status: str 415 | # moves: list[ChessDBMoveType] 416 | # ply: str (duplicate) 417 | score: int 418 | # depth: int (duplicate) 419 | pv: list[str] 420 | pvSAN: list[str] 421 | move: str 422 | egtb: str 423 | 424 | # all 425 | moves: list[OnlineMoveType] 426 | 427 | 428 | class TokenTestType(TypedDict, total=False): 429 | """Type hint for token test.""" 430 | 431 | scopes: str 432 | userId: str 433 | expires: int 434 | 435 | 436 | TOKEN_TESTS_TYPE = dict[str, TokenTestType] 437 | 438 | 439 | class _BackoffDetails(TypedDict): 440 | """`_Details` from `backoff._typing`.""" 441 | 442 | target: Callable[..., Any] 443 | args: tuple[Any, ...] 444 | kwargs: dict[str, Any] 445 | tries: int 446 | elapsed: float 447 | 448 | 449 | class BackoffDetails(_BackoffDetails, total=False): 450 | """`Details` from `backoff._typing`.""" 451 | 452 | wait: float # present in the on_backoff handler case for either decorator 453 | value: Any # present in the on_predicate decorator case 454 | 455 | 456 | ENGINE_INPUT_ARGS_TYPE = Union[None, OPTIONS_TYPE, type[BaseException], BaseException, TracebackType, Board, Limit, str, bool] 457 | ENGINE_INPUT_KWARGS_TYPE = Union[None, int, bool, list[Move], Opponent] 458 | -------------------------------------------------------------------------------- /lib/matchmaking.py: -------------------------------------------------------------------------------- 1 | """Challenge other bots.""" 2 | import random 3 | import logging 4 | import datetime 5 | import contextlib 6 | from lib import model 7 | from lib.timer import Timer, seconds, minutes, days, years 8 | from collections import defaultdict 9 | from collections.abc import Sequence 10 | from lib.lichess import Lichess 11 | from lib.config import Configuration 12 | from typing import Optional, Union 13 | from lib.lichess_types import UserProfileType, PerfType, EventType, FilterType 14 | MULTIPROCESSING_LIST_TYPE = Sequence[model.Challenge] 15 | DAILY_TIMERS_TYPE = list[Timer] 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | daily_challenges_file_name = "daily_challenge_times.txt" 20 | timestamp_format = "%Y-%m-%d %H:%M:%S\n" 21 | 22 | 23 | def read_daily_challenges() -> DAILY_TIMERS_TYPE: 24 | """Read the challenges we have created in the past 24 hours from a text file.""" 25 | timers: DAILY_TIMERS_TYPE = [] 26 | try: 27 | with open(daily_challenges_file_name) as file: 28 | for line in file: 29 | timers.append(Timer(days(1), datetime.datetime.strptime(line, timestamp_format))) 30 | except FileNotFoundError: 31 | pass 32 | 33 | return [timer for timer in timers if not timer.is_expired()] 34 | 35 | 36 | def write_daily_challenges(daily_challenges: DAILY_TIMERS_TYPE) -> None: 37 | """Write the challenges we have created in the past 24 hours to a text file.""" 38 | with open(daily_challenges_file_name, "w") as file: 39 | for timer in daily_challenges: 40 | file.write(timer.starting_timestamp(timestamp_format)) 41 | 42 | 43 | class Matchmaking: 44 | """Challenge other bots.""" 45 | 46 | def __init__(self, li: Lichess, config: Configuration, user_profile: UserProfileType) -> None: 47 | """Initialize values needed for matchmaking.""" 48 | self.li = li 49 | self.variants = list(filter(lambda variant: variant != "fromPosition", config.challenge.variants)) 50 | self.matchmaking_cfg = config.matchmaking 51 | self.user_profile = user_profile 52 | self.last_challenge_created_delay = Timer(seconds(25)) # Challenges expire after 20 seconds. 53 | self.last_game_ended_delay = Timer(minutes(self.matchmaking_cfg.challenge_timeout)) 54 | self.last_user_profile_update_time = Timer(minutes(5)) 55 | self.min_wait_time = seconds(60) # Wait before new challenge to avoid api rate limits. 56 | 57 | # Maximum time between challenges, even if there are active games 58 | self.max_wait_time = minutes(10) if self.matchmaking_cfg.allow_during_games else years(10) 59 | self.challenge_id = "" 60 | self.daily_challenges = read_daily_challenges() 61 | 62 | # (opponent name, game aspect) --> other bot is likely to accept challenge 63 | # game aspect is the one the challenged bot objects to and is one of: 64 | # - game speed (bullet, blitz, etc.) 65 | # - variant (standard, horde, etc.) 66 | # - casual/rated 67 | # - empty string (if no other reason is given or self.filter_type is COARSE) 68 | self.challenge_type_acceptable: defaultdict[tuple[str, str], bool] = defaultdict(lambda: True) 69 | self.challenge_filter = self.matchmaking_cfg.challenge_filter 70 | 71 | for name in self.matchmaking_cfg.block_list: 72 | self.add_to_block_list(name) 73 | 74 | def should_create_challenge(self) -> bool: 75 | """Whether we should create a challenge.""" 76 | matchmaking_enabled = self.matchmaking_cfg.allow_matchmaking 77 | time_has_passed = self.last_game_ended_delay.is_expired() 78 | challenge_expired = self.last_challenge_created_delay.is_expired() and self.challenge_id 79 | min_wait_time_passed = self.last_challenge_created_delay.time_since_reset() > self.min_wait_time 80 | if challenge_expired: 81 | self.li.cancel(self.challenge_id) 82 | logger.info(f"Challenge id {self.challenge_id} cancelled.") 83 | self.discard_challenge(self.challenge_id) 84 | self.show_earliest_challenge_time() 85 | return bool(matchmaking_enabled and (time_has_passed or challenge_expired) and min_wait_time_passed) 86 | 87 | def create_challenge(self, username: str, base_time: int, increment: int, days: int, variant: str, 88 | mode: str) -> str: 89 | """Create a challenge.""" 90 | params: dict[str, Union[str, int, bool]] = {"rated": mode == "rated", "variant": variant} 91 | 92 | if days: 93 | params["days"] = days 94 | elif base_time or increment: 95 | params["clock.limit"] = base_time 96 | params["clock.increment"] = increment 97 | else: 98 | logger.error("At least one of challenge_days, challenge_initial_time, or challenge_increment " 99 | "must be greater than zero in the matchmaking section of your config file.") 100 | return "" 101 | 102 | try: 103 | self.update_daily_challenge_record() 104 | self.last_challenge_created_delay.reset() 105 | response = self.li.challenge(username, params) 106 | challenge_id = response.get("id", "") 107 | if not challenge_id: 108 | logger.error(response) 109 | self.add_to_block_list(username) 110 | self.show_earliest_challenge_time() 111 | return challenge_id 112 | except Exception as e: 113 | logger.warning("Could not create challenge") 114 | logger.debug(e, exc_info=e) 115 | self.show_earliest_challenge_time() 116 | return "" 117 | 118 | def update_daily_challenge_record(self) -> None: 119 | """ 120 | Record timestamp of latest challenge and update minimum wait time. 121 | 122 | As the number of challenges in a day increase, the minimum wait time between challenges increases. 123 | 0 - 49 challenges --> 1 minute 124 | 50 - 99 challenges --> 2 minutes 125 | 100 - 149 challenges --> 3 minutes 126 | etc. 127 | """ 128 | self.daily_challenges = [timer for timer in self.daily_challenges if not timer.is_expired()] 129 | self.daily_challenges.append(Timer(days(1))) 130 | self.min_wait_time = seconds(60) * ((len(self.daily_challenges) // 50) + 1) 131 | write_daily_challenges(self.daily_challenges) 132 | 133 | def perf(self) -> dict[str, PerfType]: 134 | """Get the bot's rating in every variant. Bullet, blitz, rapid etc. are considered different variants.""" 135 | user_perf: dict[str, PerfType] = self.user_profile["perfs"] 136 | return user_perf 137 | 138 | def username(self) -> str: 139 | """Our username.""" 140 | username: str = self.user_profile["username"] 141 | return username 142 | 143 | def update_user_profile(self) -> None: 144 | """Update our user profile data, to get our latest rating.""" 145 | if self.last_user_profile_update_time.is_expired(): 146 | self.last_user_profile_update_time.reset() 147 | with contextlib.suppress(Exception): 148 | self.user_profile = self.li.get_profile() 149 | 150 | def get_weights(self, online_bots: list[UserProfileType], rating_preference: str, min_rating: int, max_rating: int, 151 | game_type: str) -> list[int]: 152 | """Get the weight for each bot. A higher weights means the bot is more likely to get challenged.""" 153 | def rating(bot: UserProfileType) -> int: 154 | perfs: dict[str, PerfType] = bot.get("perfs", {}) 155 | perf: PerfType = perfs.get(game_type, {}) 156 | return perf.get("rating", 0) 157 | 158 | if rating_preference == "high": 159 | # A bot with max_rating rating will be twice as likely to get picked than a bot with min_rating rating. 160 | reduce_ratings_by = min(min_rating - (max_rating - min_rating), min_rating - 1) 161 | weights = [rating(bot) - reduce_ratings_by for bot in online_bots] 162 | elif rating_preference == "low": 163 | # A bot with min_rating rating will be twice as likely to get picked than a bot with max_rating rating. 164 | reduce_ratings_by = max(max_rating - (min_rating - max_rating), max_rating + 1) 165 | weights = [reduce_ratings_by - rating(bot) for bot in online_bots] 166 | else: 167 | weights = [1] * len(online_bots) 168 | return weights 169 | 170 | def choose_opponent(self) -> tuple[Optional[str], int, int, int, str, str]: 171 | """Choose an opponent.""" 172 | override_choice = random.choice(self.matchmaking_cfg.overrides.keys() + [None]) 173 | logger.info(f"Using the {override_choice or 'default'} matchmaking configuration.") 174 | override = {} if override_choice is None else self.matchmaking_cfg.overrides.lookup(override_choice) 175 | match_config = self.matchmaking_cfg | override 176 | 177 | variant = self.get_random_config_value(match_config, "challenge_variant", self.variants) 178 | mode = self.get_random_config_value(match_config, "challenge_mode", ["casual", "rated"]) 179 | rating_preference = match_config.rating_preference 180 | 181 | base_time = random.choice(match_config.challenge_initial_time) 182 | increment = random.choice(match_config.challenge_increment) 183 | days = random.choice(match_config.challenge_days) 184 | 185 | play_correspondence = [bool(days), not bool(base_time or increment)] 186 | if random.choice(play_correspondence): 187 | base_time = 0 188 | increment = 0 189 | else: 190 | days = 0 191 | 192 | game_type = game_category(variant, base_time, increment, days) 193 | 194 | min_rating = match_config.opponent_min_rating 195 | max_rating = match_config.opponent_max_rating 196 | rating_diff = match_config.opponent_rating_difference 197 | bot_rating = self.perf().get(game_type, {}).get("rating", 0) 198 | if rating_diff is not None and bot_rating > 0: 199 | min_rating = bot_rating - rating_diff 200 | max_rating = bot_rating + rating_diff 201 | logger.info(f"Seeking {game_type} game with opponent rating in [{min_rating}, {max_rating}] ...") 202 | 203 | def is_suitable_opponent(bot: UserProfileType) -> bool: 204 | perf = bot.get("perfs", {}).get(game_type, {}) 205 | return (bot["username"] != self.username() 206 | and not self.in_block_list(bot["username"]) 207 | and perf.get("games", 0) > 0 208 | and min_rating <= perf.get("rating", 0) <= max_rating) 209 | 210 | online_bots = self.li.get_online_bots() 211 | online_bots = list(filter(is_suitable_opponent, online_bots)) 212 | 213 | def ready_for_challenge(bot: UserProfileType) -> bool: 214 | aspects = [variant, game_type, mode] if self.challenge_filter == FilterType.FINE else [] 215 | return all(self.should_accept_challenge(bot["username"], aspect) for aspect in aspects) 216 | 217 | ready_bots = list(filter(ready_for_challenge, online_bots)) 218 | online_bots = ready_bots or online_bots 219 | bot_username = None 220 | weights = self.get_weights(online_bots, rating_preference, min_rating, max_rating, game_type) 221 | 222 | try: 223 | bot = random.choices(online_bots, weights=weights)[0] 224 | bot_profile = self.li.get_public_data(bot["username"]) 225 | if bot_profile.get("blocking"): 226 | self.add_to_block_list(bot["username"]) 227 | else: 228 | bot_username = bot["username"] 229 | except Exception: 230 | if online_bots: 231 | logger.exception("Error:") 232 | else: 233 | logger.error("No suitable bots found to challenge.") 234 | 235 | return bot_username, base_time, increment, days, variant, mode 236 | 237 | def get_random_config_value(self, config: Configuration, parameter: str, choices: list[str]) -> str: 238 | """Choose a random value from `choices` if the parameter value in the config is `random`.""" 239 | value: str = config.lookup(parameter) 240 | return value if value != "random" else random.choice(choices) 241 | 242 | def challenge(self, active_games: set[str], challenge_queue: MULTIPROCESSING_LIST_TYPE, max_games: int) -> None: 243 | """ 244 | Challenge an opponent. 245 | 246 | :param active_games: The games that the bot is playing. 247 | :param challenge_queue: The queue containing the challenges. 248 | :param max_games: The maximum allowed number of simultaneous games. 249 | """ 250 | max_games_for_matchmaking = max_games if self.matchmaking_cfg.allow_during_games else min(1, max_games) 251 | game_count = len(active_games) + len(challenge_queue) 252 | if (game_count >= max_games_for_matchmaking 253 | or (game_count > 0 and self.last_challenge_created_delay.time_since_reset() < self.max_wait_time) 254 | or not self.should_create_challenge()): 255 | return 256 | 257 | logger.info("Challenging a random bot") 258 | self.update_user_profile() 259 | bot_username, base_time, increment, days, variant, mode = self.choose_opponent() 260 | logger.info(f"Will challenge {bot_username} for a {variant} game.") 261 | challenge_id = self.create_challenge(bot_username, base_time, increment, days, variant, mode) if bot_username else "" 262 | logger.info(f"Challenge id is {challenge_id if challenge_id else 'None'}.") 263 | self.challenge_id = challenge_id 264 | 265 | def discard_challenge(self, challenge_id: str) -> None: 266 | """ 267 | Clear the ID of the most recent challenge if it is no longer needed. 268 | 269 | :param challenge_id: The ID of the challenge that is expired, accepted, or declined. 270 | """ 271 | if self.challenge_id == challenge_id: 272 | self.challenge_id = "" 273 | 274 | def game_done(self) -> None: 275 | """Reset the timer for when the last game ended, and prints the earliest that the next challenge will be created.""" 276 | self.last_game_ended_delay.reset() 277 | self.show_earliest_challenge_time() 278 | 279 | def show_earliest_challenge_time(self) -> None: 280 | """Show the earliest that the next challenge will be created.""" 281 | if self.matchmaking_cfg.allow_matchmaking: 282 | postgame_timeout = self.last_game_ended_delay.time_until_expiration() 283 | time_to_next_challenge = self.min_wait_time - self.last_challenge_created_delay.time_since_reset() 284 | time_left = max(postgame_timeout, time_to_next_challenge) 285 | earliest_challenge_time = datetime.datetime.now() + time_left 286 | challenges = "challenge" + ("" if len(self.daily_challenges) == 1 else "s") 287 | logger.info(f"Next challenge will be created after {earliest_challenge_time.strftime('%X')} " 288 | f"({len(self.daily_challenges)} {challenges} in last 24 hours)") 289 | 290 | def add_to_block_list(self, username: str) -> None: 291 | """Add a bot to the blocklist.""" 292 | self.add_challenge_filter(username, "") 293 | 294 | def in_block_list(self, username: str) -> bool: 295 | """Check if an opponent is in the block list to prevent future challenges.""" 296 | return not self.should_accept_challenge(username, "") 297 | 298 | def add_challenge_filter(self, username: str, game_aspect: str) -> None: 299 | """ 300 | Prevent creating another challenge when an opponent has decline a challenge. 301 | 302 | :param username: The name of the opponent. 303 | :param game_aspect: The aspect of a game (time control, chess variant, etc.) 304 | that caused the opponent to decline a challenge. If the parameter is empty, 305 | that is equivalent to adding the opponent to the block list. 306 | """ 307 | self.challenge_type_acceptable[(username, game_aspect)] = False 308 | 309 | def should_accept_challenge(self, username: str, game_aspect: str) -> bool: 310 | """ 311 | Whether a bot is likely to accept a challenge to a game. 312 | 313 | :param username: The name of the opponent. 314 | :param game_aspect: A category of the challenge type (time control, chess variant, etc.) to test for acceptance. 315 | If game_aspect is empty, this is equivalent to checking if the opponent is in the block list. 316 | """ 317 | return self.challenge_type_acceptable[(username, game_aspect)] 318 | 319 | def accepted_challenge(self, event: EventType) -> None: 320 | """ 321 | Set the challenge id to an empty string, if the challenge was accepted. 322 | 323 | Otherwise, we would attempt to cancel the challenge later. 324 | """ 325 | self.discard_challenge(event["game"]["id"]) 326 | 327 | def declined_challenge(self, event: EventType) -> None: 328 | """ 329 | Handle a challenge that was declined by the opponent. 330 | 331 | Depends on whether `FilterType` is `NONE`, `COARSE`, or `FINE`. 332 | """ 333 | challenge = model.Challenge(event["challenge"], self.user_profile) 334 | opponent = challenge.challenge_target 335 | reason = event["challenge"]["declineReason"] 336 | logger.info(f"{opponent} declined {challenge}: {reason}") 337 | self.discard_challenge(challenge.id) 338 | if not challenge.from_self or self.challenge_filter == FilterType.NONE: 339 | return 340 | 341 | mode = "rated" if challenge.rated else "casual" 342 | decline_details: dict[str, str] = {"generic": "", 343 | "later": "", 344 | "nobot": "", 345 | "toofast": challenge.speed, 346 | "tooslow": challenge.speed, 347 | "timecontrol": challenge.speed, 348 | "rated": mode, 349 | "casual": mode, 350 | "standard": challenge.variant, 351 | "variant": challenge.variant} 352 | 353 | reason_key = event["challenge"]["declineReasonKey"].lower() 354 | if reason_key not in decline_details: 355 | logger.warning(f"Unknown decline reason received: {reason_key}") 356 | game_problem = decline_details.get(reason_key, "") if self.challenge_filter == FilterType.FINE else "" 357 | self.add_challenge_filter(opponent.name, game_problem) 358 | logger.info(f"Will not challenge {opponent} to another {game_problem}".strip() + " game.") 359 | 360 | self.show_earliest_challenge_time() 361 | 362 | 363 | def game_category(variant: str, base_time: int, increment: int, days: int) -> str: 364 | """ 365 | Get the game type (e.g. bullet, atomic, classical). Lichess has one rating for every variant regardless of time control. 366 | 367 | :param variant: The game's variant. 368 | :param base_time: The base time in seconds. 369 | :param increment: The increment in seconds. 370 | :param days: If the game is correspondence, we have some days to play the move. 371 | :return: The game category. 372 | """ 373 | game_duration = base_time + increment * 40 374 | if variant != "standard": 375 | return variant 376 | if days: 377 | return "correspondence" 378 | if game_duration < 179: 379 | return "bullet" 380 | if game_duration < 479: 381 | return "blitz" 382 | if game_duration < 1499: 383 | return "rapid" 384 | return "classical" 385 | -------------------------------------------------------------------------------- /lib/model.py: -------------------------------------------------------------------------------- 1 | """Store information about a challenge, game or player in a class.""" 2 | import math 3 | from urllib.parse import urljoin 4 | import logging 5 | import datetime 6 | from enum import Enum 7 | from lib.timer import Timer, msec, seconds, sec_str, to_msec, to_seconds, years 8 | from lib.config import Configuration 9 | from collections import defaultdict, Counter 10 | from lib.lichess_types import UserProfileType, ChallengeType, GameEventType, PlayerType 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Challenge: 16 | """Store information about a challenge.""" 17 | 18 | def __init__(self, challenge_info: ChallengeType, user_profile: UserProfileType) -> None: 19 | """:param user_profile: Information about our bot.""" 20 | self.id = challenge_info["id"] 21 | self.rated = challenge_info["rated"] 22 | self.variant = challenge_info["variant"]["key"] 23 | self.perf_name = challenge_info["perf"]["name"] 24 | self.speed = challenge_info["speed"] 25 | self.increment = challenge_info.get("timeControl", {}).get("increment") 26 | self.base = challenge_info.get("timeControl", {}).get("limit") 27 | self.days = challenge_info.get("timeControl", {}).get("daysPerTurn") 28 | self.challenger = Player(challenge_info.get("challenger") or {}) 29 | self.challenge_target = Player(challenge_info.get("destUser") or {}) 30 | self.from_self = self.challenger.name == user_profile["username"] 31 | self.initial_fen = challenge_info.get("initialFen", "startpos") 32 | color = challenge_info["color"] 33 | self.color = color if color != "random" else challenge_info["finalColor"] 34 | self.time_control = challenge_info["timeControl"] 35 | 36 | def is_supported_variant(self, challenge_cfg: Configuration) -> bool: 37 | """Check whether the variant is supported.""" 38 | return self.variant in challenge_cfg.variants 39 | 40 | def is_supported_time_control(self, challenge_cfg: Configuration) -> bool: 41 | """Check whether the time control is supported.""" 42 | speeds = challenge_cfg.time_controls 43 | increment_max: int = challenge_cfg.max_increment 44 | increment_min: int = challenge_cfg.min_increment 45 | base_max: int = challenge_cfg.max_base 46 | base_min: int = challenge_cfg.min_base 47 | days_max: float = challenge_cfg.max_days 48 | days_min: float = challenge_cfg.min_days 49 | 50 | if self.speed not in speeds: 51 | return False 52 | 53 | require_non_zero_increment = (self.challenger.is_bot 54 | and self.speed == "bullet" 55 | and challenge_cfg.bullet_requires_increment) 56 | increment_min = max(increment_min, 1 if require_non_zero_increment else 0) 57 | 58 | if self.base is not None and self.increment is not None: 59 | # Normal clock game 60 | return (increment_min <= self.increment <= increment_max 61 | and base_min <= self.base <= base_max) 62 | elif self.days is not None: 63 | # Correspondence game 64 | return days_min <= self.days <= days_max 65 | else: 66 | # Unlimited game 67 | return days_max == math.inf 68 | 69 | def is_supported_mode(self, challenge_cfg: Configuration) -> bool: 70 | """Check whether the mode is supported.""" 71 | return ("rated" if self.rated else "casual") in challenge_cfg.modes 72 | 73 | def is_supported_recent(self, config: Configuration, recent_bot_challenges: defaultdict[str, list[Timer]]) -> bool: 74 | """Check whether we have played a lot of games with this opponent recently. Only used when the opponent is a BOT.""" 75 | # Filter out old challenges 76 | recent_bot_challenges[self.challenger.name] = [timer for timer 77 | in recent_bot_challenges[self.challenger.name] 78 | if not timer.is_expired()] 79 | max_recent_challenges = config.max_recent_bot_challenges 80 | return (not self.challenger.is_bot 81 | or max_recent_challenges is None 82 | or len(recent_bot_challenges[self.challenger.name]) < max_recent_challenges) 83 | 84 | def decline_due_to(self, requirement_met: bool, decline_reason: str) -> str: 85 | """ 86 | Get the reason lichess-bot declined an incoming challenge. 87 | 88 | :param requirement_met: Whether a requirement is met. 89 | :param decline_reason: The reason we declined the challenge if the requirement wasn't met. 90 | :return: `decline_reason` if `requirement_met` is false else returns an empty string. 91 | """ 92 | return "" if requirement_met else decline_reason 93 | 94 | def is_supported(self, config: Configuration, recent_bot_challenges: defaultdict[str, list[Timer]], 95 | opponent_engagements: Counter[str]) -> tuple[bool, str]: 96 | """Whether the challenge is supported.""" 97 | try: 98 | if self.from_self: 99 | return True, "" 100 | 101 | from extra_game_handlers import is_supported_extra 102 | 103 | allowed_opponents: list[str] = list(filter(None, config.allow_list)) or [self.challenger.name] 104 | decline_reason = (self.decline_due_to(config.accept_bot or not self.challenger.is_bot, "noBot") 105 | or self.decline_due_to(not config.only_bot or self.challenger.is_bot, "onlyBot") 106 | or self.decline_due_to(self.is_supported_time_control(config), "timeControl") 107 | or self.decline_due_to(self.is_supported_variant(config), "variant") 108 | or self.decline_due_to(self.is_supported_mode(config), "casual" if self.rated else "rated") 109 | or self.decline_due_to(self.challenger.name not in config.block_list, "generic") 110 | or self.decline_due_to(self.challenger.name in allowed_opponents, "generic") 111 | or self.decline_due_to(self.is_supported_recent(config, recent_bot_challenges), "later") 112 | or self.decline_due_to(opponent_engagements[self.challenger.name] 113 | < config.max_simultaneous_games_per_user, "later") 114 | or self.decline_due_to(is_supported_extra(self), "generic")) 115 | 116 | return not decline_reason, decline_reason 117 | 118 | except Exception: 119 | logger.exception(f"Error while checking challenge {self.id}:") 120 | return False, "generic" 121 | 122 | def score(self) -> int: 123 | """Give a rating estimate to the opponent.""" 124 | rated_bonus = 200 if self.rated else 0 125 | challenger_master_title = self.challenger.title if not self.challenger.is_bot else None 126 | titled_bonus = 200 if challenger_master_title else 0 127 | challenger_rating_int = self.challenger.rating or 0 128 | return challenger_rating_int + rated_bonus + titled_bonus 129 | 130 | def mode(self) -> str: 131 | """Get the mode of the challenge (rated or casual).""" 132 | return "rated" if self.rated else "casual" 133 | 134 | def __str__(self) -> str: 135 | """Get a string representation of `Challenge`.""" 136 | return f"{self.perf_name} {self.mode()} challenge from {self.challenger} ({self.id})" 137 | 138 | def __repr__(self) -> str: 139 | """Get a string representation of `Challenge`.""" 140 | return self.__str__() 141 | 142 | 143 | class Termination(str, Enum): 144 | """The possible game terminations.""" 145 | 146 | MATE = "mate" 147 | TIMEOUT = "outoftime" 148 | RESIGN = "resign" 149 | ABORT = "aborted" 150 | DRAW = "draw" 151 | 152 | 153 | class Game: 154 | """Store information about a game.""" 155 | 156 | def __init__(self, game_info: GameEventType, username: str, base_url: str, abort_time: datetime.timedelta) -> None: 157 | """:param abort_time: How long to wait before aborting the game.""" 158 | self.username = username 159 | self.id = game_info["id"] 160 | self.speed = game_info.get("speed") 161 | clock = game_info.get("clock") or {} 162 | ten_years_in_ms = to_msec(years(10)) 163 | self.clock_initial = msec(clock.get("initial", ten_years_in_ms)) 164 | self.clock_increment = msec(clock.get("increment", 0)) 165 | self.perf_name = (game_info.get("perf") or {}).get("name", "{perf?}") 166 | self.variant_name = game_info["variant"]["name"] 167 | self.mode = "rated" if game_info.get("rated") else "casual" 168 | self.white = Player(game_info["white"]) 169 | self.black = Player(game_info["black"]) 170 | self.initial_fen = game_info.get("initialFen") 171 | self.state = game_info["state"] 172 | self.is_white = (self.white.name or "").lower() == username.lower() 173 | self.my_color = "white" if self.is_white else "black" 174 | self.opponent_color = "black" if self.is_white else "white" 175 | self.me = self.white if self.is_white else self.black 176 | self.opponent = self.black if self.is_white else self.white 177 | self.base_url = base_url 178 | self.game_start = datetime.datetime.fromtimestamp(to_seconds(msec(game_info["createdAt"])), 179 | tz=datetime.timezone.utc) 180 | self.abort_time = Timer(abort_time) 181 | self.terminate_time = Timer(self.clock_initial + self.clock_increment + abort_time + seconds(60)) 182 | self.disconnect_time = Timer(seconds(0)) 183 | 184 | def url(self) -> str: 185 | """Get the url of the game.""" 186 | return f"{self.short_url()}/{self.my_color}" 187 | 188 | def short_url(self) -> str: 189 | """Get the short url of the game.""" 190 | return urljoin(self.base_url, self.id) 191 | 192 | def pgn_event(self) -> str: 193 | """Get the event to write in the PGN file.""" 194 | if self.variant_name in ["Standard", "From Position"]: 195 | return f"{self.mode.title()} {self.perf_name.title()} game" 196 | else: 197 | return f"{self.mode.title()} {self.variant_name} game" 198 | 199 | def time_control(self) -> str: 200 | """Get the time control of the game.""" 201 | return f"{sec_str(self.clock_initial)}+{sec_str(self.clock_increment)}" 202 | 203 | def is_abortable(self) -> bool: 204 | """Whether the game can be aborted.""" 205 | # Moves are separated by spaces. A game is abortable when less 206 | # than two moves (one from each player) have been played. 207 | return " " not in self.state["moves"] 208 | 209 | def ping(self, abort_in: datetime.timedelta, terminate_in: datetime.timedelta, disconnect_in: datetime.timedelta) -> None: 210 | """ 211 | Tell the bot when to abort, terminate, and disconnect from a game. 212 | 213 | :param abort_in: How many seconds to wait before aborting. 214 | :param terminate_in: How many seconds to wait before terminating. 215 | :param disconnect_in: How many seconds to wait before disconnecting. 216 | """ 217 | if self.is_abortable(): 218 | self.abort_time = Timer(abort_in) 219 | self.terminate_time = Timer(terminate_in) 220 | self.disconnect_time = Timer(disconnect_in) 221 | 222 | def should_abort_now(self) -> bool: 223 | """Whether we should abort the game.""" 224 | return self.is_abortable() and self.abort_time.is_expired() 225 | 226 | def should_terminate_now(self) -> bool: 227 | """Whether we should terminate the game.""" 228 | return self.terminate_time.is_expired() 229 | 230 | def should_disconnect_now(self) -> bool: 231 | """Whether we should disconnect form the game.""" 232 | return self.disconnect_time.is_expired() 233 | 234 | def my_remaining_time(self) -> datetime.timedelta: 235 | """How many seconds we have left.""" 236 | wtime = msec(self.state["wtime"]) 237 | btime = msec(self.state["btime"]) 238 | return wtime if self.is_white else btime 239 | 240 | def result(self) -> str: 241 | """Get the result of the game.""" 242 | class GameEnding(str, Enum): 243 | WHITE_WINS = "1-0" 244 | BLACK_WINS = "0-1" 245 | DRAW = "1/2-1/2" 246 | INCOMPLETE = "*" 247 | 248 | winner = self.state.get("winner") 249 | termination = self.state.get("status") 250 | 251 | if winner == "white": 252 | result = GameEnding.WHITE_WINS 253 | elif winner == "black": 254 | result = GameEnding.BLACK_WINS 255 | elif termination in [Termination.DRAW, Termination.TIMEOUT]: 256 | result = GameEnding.DRAW 257 | else: 258 | result = GameEnding.INCOMPLETE 259 | 260 | return result.value 261 | 262 | def __str__(self) -> str: 263 | """Get a string representation of `Game`.""" 264 | return f"{self.url()} {self.perf_name} vs {self.opponent} ({self.id})" 265 | 266 | def __repr__(self) -> str: 267 | """Get a string representation of `Game`.""" 268 | return self.__str__() 269 | 270 | 271 | class Player: 272 | """Store information about a player.""" 273 | 274 | def __init__(self, player_info: PlayerType) -> None: 275 | """:param player_info: Contains information about a player.""" 276 | self.title = player_info.get("title") 277 | self.rating = player_info.get("rating") 278 | self.provisional = player_info.get("provisional") 279 | self.aiLevel = player_info.get("aiLevel") 280 | self.is_bot = self.title == "BOT" or self.aiLevel is not None 281 | self.name = f"AI level {self.aiLevel}" if self.aiLevel else player_info.get("name", "") 282 | 283 | def __str__(self) -> str: 284 | """Get a string representation of `Player`.""" 285 | if self.aiLevel: 286 | return self.name 287 | rating = f'{self.rating}{"?" if self.provisional else ""}' 288 | return f'{self.title or ""} {self.name} ({rating})'.strip() 289 | 290 | def __repr__(self) -> str: 291 | """Get a string representation of `Player`.""" 292 | return self.__str__() 293 | -------------------------------------------------------------------------------- /lib/timer.py: -------------------------------------------------------------------------------- 1 | """A timer for use in lichess-bot.""" 2 | 3 | from datetime import datetime, timedelta 4 | from time import perf_counter 5 | from typing import Optional 6 | 7 | 8 | def msec(time_in_msec: float) -> timedelta: 9 | """Create a timedelta duration in milliseconds.""" 10 | return timedelta(milliseconds=time_in_msec) 11 | 12 | 13 | def to_msec(duration: timedelta) -> float: 14 | """Return a bare number representing the length of the duration in milliseconds.""" 15 | return duration / msec(1) 16 | 17 | 18 | def msec_str(duration: timedelta) -> str: 19 | """Return a string with the duration value in whole number milliseconds.""" 20 | return str(round(to_msec(duration))) 21 | 22 | 23 | def seconds(time_in_sec: float) -> timedelta: 24 | """Create a timedelta duration in seconds.""" 25 | return timedelta(seconds=time_in_sec) 26 | 27 | 28 | def to_seconds(duration: timedelta) -> float: 29 | """Return a bare number representing the length of the duration in seconds.""" 30 | return duration.total_seconds() 31 | 32 | 33 | def sec_str(duration: timedelta) -> str: 34 | """Return a string with the duration value in whole number seconds.""" 35 | return str(round(to_seconds(duration))) 36 | 37 | 38 | def minutes(time_in_minutes: float) -> timedelta: 39 | """Create a timedelta duration in minutes.""" 40 | return timedelta(minutes=time_in_minutes) 41 | 42 | 43 | def hours(time_in_hours: float) -> timedelta: 44 | """Create a timedelta duration in hours.""" 45 | return timedelta(hours=time_in_hours) 46 | 47 | 48 | def days(time_in_days: float) -> timedelta: 49 | """Create a timedelta duration in days.""" 50 | return timedelta(days=time_in_days) 51 | 52 | 53 | def years(time_in_years: float) -> timedelta: 54 | """Create a timedelta duration in median years--i.e., 365 days.""" 55 | return days(365) * time_in_years 56 | 57 | 58 | zero_seconds = seconds(0) 59 | 60 | 61 | class Timer: 62 | """ 63 | A timer for use in lichess-bot. An instance of timer can be used both as a countdown timer and a stopwatch. 64 | 65 | If the duration argument in the __init__() method is greater than zero, then 66 | the method is_expired() indicates when the intial duration has passed. The 67 | method time_until_expiration() gives the amount of time left until the timer 68 | expires. 69 | 70 | Regardless of the initial duration (even if it's zero), a timer can be used 71 | as a stopwatch by calling time_since_reset() to get the amount of time since 72 | the timer was created or since it was last reset. 73 | """ 74 | 75 | def __init__(self, duration: timedelta = zero_seconds, 76 | backdated_timestamp: Optional[datetime] = None) -> None: 77 | """ 78 | Start the timer. 79 | 80 | :param duration: The duration of time before Timer.is_expired() returns True. 81 | :param backdated_timestamp: When the timer should have started. Used to keep the timers between sessions. 82 | """ 83 | self.duration = duration 84 | self.starting_time = perf_counter() 85 | 86 | if backdated_timestamp: 87 | self.starting_time -= to_seconds(datetime.now() - backdated_timestamp) 88 | 89 | def is_expired(self) -> bool: 90 | """Check if a timer is expired.""" 91 | return self.time_since_reset() >= self.duration 92 | 93 | def reset(self) -> None: 94 | """Reset the timer.""" 95 | self.starting_time = perf_counter() 96 | 97 | def time_since_reset(self) -> timedelta: 98 | """How much time has passed.""" 99 | return seconds(perf_counter() - self.starting_time) 100 | 101 | def time_until_expiration(self) -> timedelta: 102 | """How much time is left until it expires.""" 103 | return max(seconds(0), self.duration - self.time_since_reset()) 104 | 105 | def starting_timestamp(self, timestamp_format: str) -> str: 106 | """When the timer started.""" 107 | return (datetime.now() - self.time_since_reset()).strftime(timestamp_format) 108 | -------------------------------------------------------------------------------- /lib/versioning.yml: -------------------------------------------------------------------------------- 1 | lichess_bot_version: 2025.5.30.1 2 | minimum_python_version: '3.10' 3 | deprecated_python_version: '3.9' 4 | deprecation_date: 2025-10-05 5 | -------------------------------------------------------------------------------- /lichess-bot.py: -------------------------------------------------------------------------------- 1 | """Starting point for lichess-bot.""" 2 | from lib.lichess_bot import start_program 3 | 4 | if __name__ == "__main__": 5 | start_program() 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | chess~=1.11 2 | PyYAML~=6.0 3 | requests~=2.32 4 | backoff~=2.2 5 | rich~=14.0 6 | -------------------------------------------------------------------------------- /test_bot/__init__.py: -------------------------------------------------------------------------------- 1 | """pytest won't search `test_bot/` if there is no `__init__.py` file.""" 2 | -------------------------------------------------------------------------------- /test_bot/buggy_engine: -------------------------------------------------------------------------------- 1 | #!/usr/bin/sh 2 | 3 | python3 test_bot/buggy_engine.py 4 | -------------------------------------------------------------------------------- /test_bot/buggy_engine.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | python test_bot\buggy_engine.py 3 | -------------------------------------------------------------------------------- /test_bot/buggy_engine.py: -------------------------------------------------------------------------------- 1 | """An engine that takes too much time to make a move during tests.""" 2 | 3 | import chess 4 | import time 5 | 6 | assert input() == "uci" 7 | 8 | 9 | def send_command(command: str) -> None: 10 | """Send UCI commands to lichess-bot without output buffering.""" 11 | print(command, flush=True) # noqa: T201 (print() found) 12 | 13 | 14 | send_command("id name Procrastinator") 15 | send_command("id author MZH") 16 | send_command("uciok") 17 | 18 | delay_performed = False 19 | just_started = True 20 | scholars_mate = ["a2a3", "e7e5", "a3a4", "f8c5", "a4a5", "d8h4", "a5a6", "h4f2"] 21 | 22 | while True: 23 | command, *remaining = input().split() 24 | if command == "quit": 25 | break 26 | elif command == "isready": 27 | send_command("readyok") 28 | elif command == "position": 29 | spec_type, *remaining = remaining 30 | assert spec_type == "startpos" 31 | board = chess.Board() 32 | if remaining: 33 | moves_label, *move_list = remaining 34 | assert moves_label == "moves" 35 | for move in move_list: 36 | board.push_uci(move) 37 | if just_started and len(board.move_stack) > 1: 38 | delay_performed = True 39 | elif command == "go": 40 | move_count = len(board.move_stack) 41 | if move_count == 3 and not delay_performed: 42 | send_command("info string delaying move") 43 | delay_performed = True 44 | time.sleep(11) 45 | move = scholars_mate[move_count] 46 | send_command(f"bestmove {move}") 47 | just_started = False 48 | -------------------------------------------------------------------------------- /test_bot/buggy_engine_macos: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | python3 test_bot/buggy_engine.py 4 | -------------------------------------------------------------------------------- /test_bot/conftest.py: -------------------------------------------------------------------------------- 1 | """Remove files created when testing lichess-bot.""" 2 | import shutil 3 | import os 4 | from _pytest.config import ExitCode 5 | from _pytest.main import Session 6 | from typing import Union 7 | 8 | 9 | def pytest_sessionfinish(session: Session, exitstatus: Union[int, ExitCode]) -> None: # noqa: ARG001 10 | """ 11 | Remove files created when testing lichess-bot. 12 | 13 | The only exception is if running in a GitHub action, in which case we save the engines to the cache. 14 | """ 15 | if os.path.exists("TEMP") and not os.getenv("GITHUB_ACTIONS"): 16 | shutil.rmtree("TEMP") 17 | -------------------------------------------------------------------------------- /test_bot/homemade.py: -------------------------------------------------------------------------------- 1 | """Homemade engine using Stockfish (used in testing).""" 2 | from homemade import ExampleEngine 3 | import chess 4 | import chess.engine 5 | import sys 6 | from lib.config import Configuration 7 | from lib import model 8 | from typing import Optional 9 | from lib.lichess_types import OPTIONS_GO_EGTB_TYPE, COMMANDS_TYPE, MOVE 10 | 11 | # ruff: noqa: ARG002 12 | 13 | platform = sys.platform 14 | file_extension = ".exe" if platform == "win32" else "" 15 | 16 | 17 | class Stockfish(ExampleEngine): 18 | """A homemade engine that uses Stockfish.""" 19 | 20 | def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: Optional[int], 21 | draw_or_resign: Configuration, game: Optional[model.Game], **popen_args: str) -> None: 22 | """Start Stockfish.""" 23 | super().__init__(commands, options, stderr, draw_or_resign, game, **popen_args) 24 | self.engine = chess.engine.SimpleEngine.popen_uci(f"./TEMP/sf{file_extension}") 25 | 26 | def search(self, board: chess.Board, time_limit: chess.engine.Limit, ponder: bool, draw_offered: bool, 27 | root_moves: MOVE) -> chess.engine.PlayResult: 28 | """Get a move using Stockfish.""" 29 | return self.engine.play(board, time_limit) 30 | -------------------------------------------------------------------------------- /test_bot/lichess.py: -------------------------------------------------------------------------------- 1 | """Imitate `lichess.py`. Used in tests.""" 2 | import time 3 | import chess.engine 4 | import json 5 | import logging 6 | import traceback 7 | import datetime 8 | from queue import Queue 9 | from requests.models import Response 10 | from typing import Union, Optional, Generator 11 | from lib.lichess import Lichess as OriginalLichess 12 | from lib.timer import to_msec 13 | from lib.lichess_types import (UserProfileType, ChallengeType, REQUESTS_PAYLOAD_TYPE, GameType, OnlineType, PublicDataType, 14 | BackoffDetails) 15 | 16 | # ruff: noqa: ARG002 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def backoff_handler(details: BackoffDetails) -> None: 22 | """Log exceptions inside functions with the backoff decorator.""" 23 | logger.debug("Backing off {wait:0.1f} seconds after {tries} tries " 24 | "calling function {target} with args {args} and kwargs {kwargs}".format(**details)) 25 | logger.debug(f"Exception: {traceback.format_exc()}") 26 | 27 | 28 | def is_final(error: Exception) -> bool: 29 | """Mock error handler for tests when a function has a backup decorator.""" 30 | logger.debug(error) 31 | return False 32 | 33 | 34 | class GameStream(Response): 35 | """Imitate lichess.org's GameStream. Used in tests.""" 36 | 37 | def __init__(self, 38 | board_queue: Queue[chess.Board], 39 | clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]]) -> None: 40 | """ 41 | Capture the interprocess queues that will feed the gameStream with game information. 42 | 43 | :param board_queue: Updated board positions from the lichess_org_simulator() function. 44 | :param clock_queue: Updated game clock timings (white time, black time, and increment) from the 45 | lichess_org_simulator() function. 46 | """ 47 | self.board_queue = board_queue 48 | self.clock_queue = clock_queue 49 | 50 | def iter_lines(self, chunk_size: Optional[int] = 512, decode_unicode: bool = False, 51 | delimiter: Union[str, bytes, None] = None) -> Generator[bytes, None, None]: 52 | """Send the game events to lichess-bot.""" 53 | yield json.dumps( 54 | {"id": "zzzzzzzz", 55 | "variant": {"key": "standard", 56 | "name": "Standard", 57 | "short": "Std"}, 58 | "clock": {"initial": 60000, 59 | "increment": 2000}, 60 | "speed": "bullet", 61 | "perf": {"name": "Bullet"}, 62 | "rated": True, 63 | "createdAt": 1600000000000, 64 | "white": {"id": "bo", 65 | "name": "bo", 66 | "title": "BOT", 67 | "rating": 3000}, 68 | "black": {"id": "b", 69 | "name": "b", 70 | "title": "BOT", 71 | "rating": 3000, 72 | "provisional": True}, 73 | "initialFen": "startpos", 74 | "type": "gameFull", 75 | "state": {"type": "gameState", 76 | "moves": "", 77 | "wtime": 10000, 78 | "btime": 10000, 79 | "winc": 100, 80 | "binc": 100, 81 | "status": "started"}}).encode("utf-8") 82 | while True: 83 | board = self.board_queue.get() 84 | self.board_queue.task_done() 85 | 86 | wtime, btime, increment = self.clock_queue.get() 87 | self.clock_queue.task_done() 88 | 89 | new_game_state = {"type": "gameState", 90 | "moves": " ".join(move.uci() for move in board.move_stack), 91 | "wtime": int(to_msec(wtime)), 92 | "btime": int(to_msec(btime)), 93 | "winc": int(to_msec(increment)), 94 | "binc": int(to_msec(increment))} 95 | 96 | if board.is_game_over(): 97 | new_game_state["status"] = "outoftime" 98 | new_game_state["winner"] = "black" 99 | yield json.dumps(new_game_state).encode("utf-8") 100 | break 101 | 102 | if board.move_stack: 103 | new_game_state["status"] = "started" 104 | yield json.dumps(new_game_state).encode("utf-8") 105 | 106 | 107 | class EventStream(Response): 108 | """Imitate lichess.org's EventStream. Used in tests.""" 109 | 110 | def __init__(self, sent_game: bool = False) -> None: 111 | """ 112 | Start the event stream for the lichess_bot_main() loop. 113 | 114 | :param sent_game: If we have already sent the `gameStart` event, so we don't send it again. 115 | """ 116 | self.sent_game = sent_game 117 | 118 | def iter_lines(self, chunk_size: Optional[int] = 512, decode_unicode: bool = False, 119 | delimiter: Union[str, bytes, None] = None) -> Generator[bytes, None, None]: 120 | """Send the events to lichess-bot.""" 121 | if self.sent_game: 122 | yield b"" 123 | time.sleep(1) 124 | else: 125 | yield json.dumps( 126 | {"type": "gameStart", 127 | "game": {"id": "zzzzzzzz", 128 | "source": "friend", 129 | "compat": {"bot": True, 130 | "board": True}}}).encode("utf-8") 131 | 132 | 133 | # Docs: https://lichess.org/api. 134 | class Lichess(OriginalLichess): 135 | """Imitate communication with lichess.org.""" 136 | 137 | def __init__(self, 138 | move_queue: Queue[Optional[chess.Move]], 139 | board_queue: Queue[chess.Board], 140 | clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]]) -> None: 141 | """ 142 | Capture the interprocess queues to distribute them to the eventStream and gameStream instances. 143 | 144 | :param move_queue: An interprocess queue to send moves chosen by the bot under test to the mock lichess function. 145 | :param board_queue: An interprocess queue to send board positions to the mock game stream. 146 | :param clock_queue: An interprocess queue to send game clock information to the mock game stream. 147 | """ 148 | self.baseUrl = "testing" 149 | self.move_queue = move_queue 150 | self.board_queue = board_queue 151 | self.clock_queue = clock_queue 152 | self.sent_game = False 153 | self.started_game_stream = False 154 | 155 | def upgrade_to_bot_account(self) -> None: 156 | """Isn't used in tests.""" 157 | 158 | def make_move(self, game_id: str, move: chess.engine.PlayResult) -> None: 159 | """Send a move to the opponent engine thread.""" 160 | self.move_queue.put(move.move) 161 | 162 | def accept_takeback(self, game_id: str, accept: bool) -> bool: 163 | """Isn't used in tests.""" 164 | return False 165 | 166 | def chat(self, game_id: str, room: str, text: str) -> None: 167 | """Isn't used in tests.""" 168 | 169 | def abort(self, game_id: str) -> None: 170 | """Isn't used in tests.""" 171 | 172 | def get_event_stream(self) -> EventStream: 173 | """Send the `EventStream`.""" 174 | events = EventStream(self.sent_game) 175 | self.sent_game = True 176 | return events 177 | 178 | def get_game_stream(self, game_id: str) -> GameStream: 179 | """Send the `GameStream`.""" 180 | if self.started_game_stream: 181 | self.move_queue.put(None) 182 | self.started_game_stream = True 183 | return GameStream(self.board_queue, self.clock_queue) 184 | 185 | def accept_challenge(self, challenge_id: str) -> None: 186 | """Isn't used in tests.""" 187 | 188 | def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None: 189 | """Isn't used in tests.""" 190 | 191 | def get_profile(self) -> UserProfileType: 192 | """Return a simple profile for the bot that lichess-bot uses when testing.""" 193 | return {"id": "b", 194 | "username": "b", 195 | "online": True, 196 | "title": "BOT", 197 | "url": "https://lichess.org/@/b", 198 | "followable": True, 199 | "following": False, 200 | "blocking": False, 201 | "followsYou": False, 202 | "perfs": {}} 203 | 204 | def get_ongoing_games(self) -> list[GameType]: 205 | """Return that the bot isn't playing a game.""" 206 | return [] 207 | 208 | def resign(self, game_id: str) -> None: 209 | """Isn't used in tests.""" 210 | 211 | def get_game_pgn(self, game_id: str) -> str: 212 | """Return a simple PGN.""" 213 | return """ 214 | [Event "Test game"] 215 | [Site "pytest"] 216 | [Date "2022.03.11"] 217 | [Round "1"] 218 | [White "bo"] 219 | [Black "b"] 220 | [Result "0-1"] 221 | 222 | * 223 | """ 224 | 225 | def get_online_bots(self) -> list[UserProfileType]: 226 | """Return that the only bot online is us.""" 227 | return [{"username": "b", "online": True}] 228 | 229 | def challenge(self, username: str, payload: REQUESTS_PAYLOAD_TYPE) -> ChallengeType: 230 | """Isn't used in tests.""" 231 | return {} 232 | 233 | def cancel(self, challenge_id: str) -> None: 234 | """Isn't used in tests.""" 235 | 236 | def online_book_get(self, path: str, params: Optional[dict[str, Union[str, int]]] = None, 237 | stream: bool = False) -> OnlineType: 238 | """Isn't used in tests.""" 239 | return {} 240 | 241 | def is_online(self, user_id: str) -> bool: 242 | """Return that a bot is online.""" 243 | return True 244 | 245 | def get_public_data(self, user_name: str) -> PublicDataType: 246 | """Isn't used in tests.""" 247 | return {} 248 | -------------------------------------------------------------------------------- /test_bot/ruff.toml: -------------------------------------------------------------------------------- 1 | target-version = "py39" 2 | 3 | # The GitHub editor is 127 chars wide. 4 | line-length = 127 5 | 6 | [lint] 7 | select = ["ALL"] 8 | 9 | ignore = [ 10 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 11 | "BLE001", # Do not catch blind exception: Exception 12 | "COM812", # Trailing comma missing 13 | "D203", # Require blank line after class declaration before docstring 14 | "D212", # Start multiline docstring on same line as triple-quote 15 | "D404", # Docstring should not start with the word "This" 16 | "DTZ", # datetime without timezone 17 | "EM101", # Exception must not use a string literal, assign to variable first 18 | "EM102", # Exception must not use an f-string literal, assign to variable first 19 | "ERA001", # Found commented-out code 20 | "FA100", # Add from __future__ import annotations to simplify typing 21 | "FBT", # Boolean argument in function definition 22 | "G", # Logging 23 | "I001", # Import block is un-sorted or un-formatted 24 | "N803", # Argument name should be lowercase 25 | "N806", # Variable in function should be lowercase 26 | "PERF203", # try-except within a loop incurs performance overhead 27 | "PLR0913", # Too many arguments in function definition 28 | "PLR0915", # Too many statements 29 | "PLR2004", # Magic value used in comparison, consider replacing `20` with a constant variable 30 | "PLW0603", # Using the global statement to update variable is discouraged 31 | "PT018", # Assertion should be broken down into multiple parts 32 | "PTH", # Replace builtin functions with Path methods 33 | "RET505", # Unnecessary else after return statement 34 | "RUF005", # Consider [*list1, None] instead of concatenation (list1 + [None]) 35 | "RUF021", # Parenthesize `a and b` expressions when chaining `and` and `or` together, to make the precedence clear 36 | "S101", # Use of assert detected 37 | "S113", # Probable use of `requests` call without timeout 38 | "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes 39 | "SIM108", #Use ternary operator instead of `if`-`else`-block 40 | "TC001", # Move application import into a type-checking block 41 | "TC003", # Move standard library import into a type-checking block 42 | "TC006", # Add quotes to type expression in `typing.cast()` 43 | "TRY", # Try-except suggestions 44 | "UP035", # Import from collections.abc instead of typing 45 | "UP007", # Use `X | Y` for type annotations 46 | ] 47 | 48 | [lint.mccabe] 49 | max-complexity = 10 50 | -------------------------------------------------------------------------------- /test_bot/test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest~=8.3 2 | pytest-timeout~=2.4 3 | ruff~=0.11 4 | mypy~=1.16 5 | types-requests~=2.32 6 | types-PyYAML~=6.0 7 | -------------------------------------------------------------------------------- /test_bot/test_bot.py: -------------------------------------------------------------------------------- 1 | """Test lichess-bot.""" 2 | import pytest 3 | import zipfile 4 | import requests 5 | import yaml 6 | import chess 7 | import chess.engine 8 | import threading 9 | import os 10 | import sys 11 | import stat 12 | import shutil 13 | import tarfile 14 | import datetime 15 | import logging 16 | from multiprocessing import Manager 17 | from queue import Queue 18 | import test_bot.lichess 19 | from lib import config 20 | from lib.timer import Timer, to_seconds, seconds 21 | from typing import Optional 22 | from lib.engine_wrapper import test_suffix 23 | from lib.lichess_types import CONFIG_DICT_TYPE 24 | if "pytest" not in sys.modules: 25 | sys.exit(f"The script {os.path.basename(__file__)} should only be run by pytest.") 26 | from lib import lichess_bot 27 | 28 | platform = sys.platform 29 | archive_ext = "zip" if platform == "win32" else "tar" 30 | file_extension = ".exe" if platform == "win32" else "" 31 | 32 | 33 | def download_sf() -> None: 34 | """Download Stockfish 16.""" 35 | stockfish_path = f"./TEMP/sf{file_extension}" 36 | if os.path.exists(stockfish_path): 37 | return 38 | 39 | windows_linux_mac = "windows" if platform == "win32" else ("macos" if platform == "darwin" else "ubuntu") 40 | sf_base = f"stockfish-{windows_linux_mac}-x86-64-modern" 41 | archive_link = f"https://github.com/official-stockfish/Stockfish/releases/download/sf_16/{sf_base}.{archive_ext}" 42 | 43 | response = requests.get(archive_link, allow_redirects=True) 44 | response.raise_for_status() 45 | archive_name = f"./TEMP/sf_zip.{archive_ext}" 46 | with open(archive_name, "wb") as file: 47 | file.write(response.content) 48 | 49 | if archive_ext == "zip": 50 | with zipfile.ZipFile(archive_name, "r") as archive_ref: 51 | archive_ref.extractall("./TEMP/") # noqa: S202 52 | else: 53 | with tarfile.TarFile(archive_name, "r") as archive_ref: 54 | archive_ref.extractall("./TEMP/", filter="data") 55 | 56 | exe_ext = ".exe" if platform == "win32" else "" 57 | shutil.copyfile(f"./TEMP/stockfish/{sf_base}{exe_ext}", stockfish_path) 58 | 59 | if platform != "win32": 60 | st = os.stat(stockfish_path) 61 | os.chmod(stockfish_path, st.st_mode | stat.S_IEXEC) 62 | 63 | 64 | def download_lc0() -> None: 65 | """Download Leela Chess Zero 0.29.0.""" 66 | if os.path.exists("./TEMP/lc0.exe"): 67 | return 68 | 69 | response = requests.get("https://github.com/LeelaChessZero/lc0/releases/download/v0.29.0/lc0-v0.29.0-windows-cpu-dnnl.zip", 70 | allow_redirects=True) 71 | response.raise_for_status() 72 | with open("./TEMP/lc0_zip.zip", "wb") as file: 73 | file.write(response.content) 74 | with zipfile.ZipFile("./TEMP/lc0_zip.zip", "r") as zip_ref: 75 | zip_ref.extractall("./TEMP/") # noqa: S202 76 | 77 | 78 | def download_arasan() -> None: 79 | """Download Arasan.""" 80 | if os.path.exists(f"./TEMP/arasan{file_extension}"): 81 | return 82 | if platform == "win32": 83 | response = requests.get("https://arasanchess.org/arasan24.1.zip", allow_redirects=True) 84 | else: 85 | response = requests.get("https://arasanchess.org/arasan-linux-binaries-24.2.2.tar.gz", allow_redirects=True) 86 | response.raise_for_status() 87 | with open(f"./TEMP/arasan.{archive_ext}", "wb") as file: 88 | file.write(response.content) 89 | if archive_ext == "zip": 90 | with zipfile.ZipFile(f"./TEMP/arasan.{archive_ext}", "r") as archive_ref: 91 | archive_ref.extractall("./TEMP/") # noqa: S202 92 | else: 93 | with tarfile.TarFile(f"./TEMP/arasan.{archive_ext}", "r") as archive_ref: 94 | archive_ref.extractall("./TEMP/", filter="data") 95 | shutil.copyfile(f"./TEMP/arasanx-64{file_extension}", f"./TEMP/arasan{file_extension}") 96 | if platform != "win32": 97 | st = os.stat(f"./TEMP/arasan{file_extension}") 98 | os.chmod(f"./TEMP/arasan{file_extension}", st.st_mode | stat.S_IEXEC) 99 | 100 | 101 | os.makedirs("TEMP", exist_ok=True) 102 | logging_level = logging.DEBUG 103 | testing_log_file_name = None 104 | lichess_bot.logging_configurer(logging_level, testing_log_file_name, True) 105 | logger = logging.getLogger(__name__) 106 | 107 | 108 | class TrivialEngine: 109 | """A trivial engine that should be trivial to beat.""" 110 | 111 | def play(self, board: chess.Board, *_: object) -> chess.engine.PlayResult: 112 | """Choose the first legal move.""" 113 | return chess.engine.PlayResult(next(iter(board.legal_moves)), None) 114 | 115 | def quit(self) -> None: 116 | """Do nothing.""" 117 | 118 | 119 | def lichess_org_simulator(opponent_path: Optional[str], 120 | move_queue: Queue[Optional[chess.Move]], 121 | board_queue: Queue[chess.Board], 122 | clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]], 123 | results: Queue[bool]) -> None: 124 | """ 125 | Run a mocked version of the lichess.org server to provide an opponent for a test. This opponent always plays white. 126 | 127 | :param opponent_path: The path to the executable of the opponent. Usually Stockfish. 128 | :param move_queue: An interprocess queue that supplies the moves chosen by the bot being tested. 129 | :param board_queue: An interprocess queue where this function sends the updated board after choosing a move. 130 | :param clock_queue: An interprocess queue where this function sends the updated game clock after choosing a move. 131 | :param results: An interprocess queue where this function sends the result of the game to the testing function. 132 | """ 133 | start_time = seconds(10) 134 | increment = seconds(0.1) 135 | 136 | board = chess.Board() 137 | wtime = start_time 138 | btime = start_time 139 | 140 | engine = chess.engine.SimpleEngine.popen_uci(opponent_path) if opponent_path else TrivialEngine() 141 | 142 | while not board.is_game_over(): 143 | if board.turn == chess.WHITE: 144 | if not board.move_stack: 145 | move = engine.play(board, chess.engine.Limit(time=1)) 146 | else: 147 | move_timer = Timer() 148 | move = engine.play(board, 149 | chess.engine.Limit(white_clock=to_seconds(wtime - seconds(2.0)), 150 | white_inc=to_seconds(increment), 151 | black_clock=to_seconds(btime), 152 | black_inc=to_seconds(increment))) 153 | wtime -= move_timer.time_since_reset() 154 | wtime += increment 155 | engine_move = move.move 156 | if engine_move is None: 157 | raise RuntimeError("Engine attempted to make null move.") 158 | board.push(engine_move) 159 | board_queue.put(board) 160 | clock_queue.put((wtime, btime, increment)) 161 | else: 162 | move_timer = Timer() 163 | while (bot_move := move_queue.get()) is None: 164 | board_queue.put(board) 165 | clock_queue.put((wtime, btime, increment)) 166 | move_queue.task_done() 167 | board.push(bot_move) 168 | move_queue.task_done() 169 | if len(board.move_stack) > 2: 170 | btime -= move_timer.time_since_reset() 171 | btime += increment 172 | 173 | board_queue.put(board) 174 | clock_queue.put((wtime, btime, increment)) 175 | engine.quit() 176 | outcome = board.outcome() 177 | results.put(outcome is not None and outcome.winner == chess.BLACK) 178 | 179 | 180 | def run_bot(raw_config: CONFIG_DICT_TYPE, logging_level: int, opponent_path: Optional[str] = None) -> bool: 181 | """ 182 | Start lichess-bot test with a mocked version of the lichess.org site. 183 | 184 | :param raw_config: A dictionary of values to specify the engine to test. This engine will play as white. 185 | :param logging_level: The level of logging to use during the test. Usually logging.DEBUG. 186 | :param opponent_path: The path to the executable that will play the opponent. The opponent plays as black. 187 | """ 188 | config.insert_default_values(raw_config) 189 | CONFIG = config.Configuration(raw_config) 190 | logger.info(lichess_bot.intro()) 191 | manager = Manager() 192 | board_queue: Queue[chess.Board] = manager.Queue() 193 | clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]] = manager.Queue() 194 | move_queue: Queue[Optional[chess.Move]] = manager.Queue() 195 | li = test_bot.lichess.Lichess(move_queue, board_queue, clock_queue) 196 | 197 | user_profile = li.get_profile() 198 | username = user_profile["username"] 199 | if user_profile.get("title") != "BOT": 200 | return False 201 | logger.info(f"Welcome {username}!") 202 | lichess_bot.disable_restart() 203 | 204 | results: Queue[bool] = manager.Queue() 205 | thr = threading.Thread(target=lichess_org_simulator, args=[opponent_path, move_queue, board_queue, clock_queue, results]) 206 | thr.start() 207 | lichess_bot.start(li, user_profile, CONFIG, logging_level, testing_log_file_name, True, one_game=True) 208 | 209 | result = results.get() 210 | results.task_done() 211 | 212 | results.join() 213 | board_queue.join() 214 | clock_queue.join() 215 | move_queue.join() 216 | 217 | thr.join() 218 | 219 | return result 220 | 221 | 222 | @pytest.mark.timeout(180, method="thread") 223 | def test_sf() -> None: 224 | """Test lichess-bot with Stockfish (UCI).""" 225 | with open("./config.yml.default") as file: 226 | CONFIG = yaml.safe_load(file) 227 | CONFIG["token"] = "" 228 | CONFIG["engine"]["dir"] = "./TEMP/" 229 | CONFIG["engine"]["name"] = f"sf{file_extension}" 230 | CONFIG["engine"]["uci_options"]["Threads"] = 1 231 | CONFIG["pgn_directory"] = "TEMP/sf_game_record" 232 | logger.info("Downloading Stockfish") 233 | try: 234 | download_sf() 235 | except Exception: 236 | logger.exception("Could not download the Stockfish chess engine") 237 | pytest.skip("Could not download the Stockfish chess engine") 238 | win = run_bot(CONFIG, logging_level) 239 | logger.info("Finished Testing SF") 240 | assert win 241 | assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], 242 | "bo vs b - zzzzzzzz.pgn")) 243 | 244 | 245 | @pytest.mark.timeout(180, method="thread") 246 | def test_lc0() -> None: 247 | """Test lichess-bot with Leela Chess Zero (UCI).""" 248 | if platform != "win32": 249 | pytest.skip("Platform must be Windows.") 250 | with open("./config.yml.default") as file: 251 | CONFIG = yaml.safe_load(file) 252 | CONFIG["token"] = "" 253 | CONFIG["engine"]["dir"] = "./TEMP/" 254 | CONFIG["engine"]["working_dir"] = "./TEMP/" 255 | CONFIG["engine"]["name"] = "lc0.exe" 256 | CONFIG["engine"]["uci_options"]["Threads"] = 1 257 | CONFIG["engine"]["uci_options"].pop("Hash", None) 258 | CONFIG["engine"]["uci_options"].pop("Move Overhead", None) 259 | CONFIG["pgn_directory"] = "TEMP/lc0_game_record" 260 | logger.info("Downloading LC0") 261 | try: 262 | download_lc0() 263 | except Exception: 264 | logger.exception("Could not download the LC0 chess engine") 265 | pytest.skip("Could not download the LC0 chess engine") 266 | win = run_bot(CONFIG, logging_level) 267 | logger.info("Finished Testing LC0") 268 | assert win 269 | assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], 270 | "bo vs b - zzzzzzzz.pgn")) 271 | 272 | 273 | @pytest.mark.timeout(150, method="thread") 274 | def test_arasan() -> None: 275 | """Test lichess-bot with Arasan (XBoard).""" 276 | if platform not in ("linux", "win32"): 277 | pytest.skip("Platform must be Windows or Linux.") 278 | with open("./config.yml.default") as file: 279 | CONFIG = yaml.safe_load(file) 280 | CONFIG["token"] = "" 281 | CONFIG["engine"]["dir"] = "./TEMP/" 282 | CONFIG["engine"]["working_dir"] = "./TEMP/" 283 | CONFIG["engine"]["protocol"] = "xboard" 284 | CONFIG["engine"]["name"] = f"arasan{file_extension}" 285 | CONFIG["engine"]["ponder"] = False 286 | CONFIG["pgn_directory"] = "TEMP/arasan_game_record" 287 | logger.info("Downloading Arasan") 288 | try: 289 | download_arasan() 290 | except Exception: 291 | logger.exception("Could not download the Arasan chess engine") 292 | pytest.skip("Could not download the Arasan chess engine") 293 | win = run_bot(CONFIG, logging_level) 294 | logger.info("Finished Testing Arasan") 295 | assert win 296 | assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], 297 | "bo vs b - zzzzzzzz.pgn")) 298 | 299 | 300 | @pytest.mark.timeout(180, method="thread") 301 | def test_homemade() -> None: 302 | """Test lichess-bot with a homemade engine running Stockfish (Homemade).""" 303 | try: 304 | download_sf() 305 | except Exception: 306 | logger.exception("Could not download the Stockfish chess engine") 307 | pytest.skip("Could not download the Stockfish chess engine") 308 | 309 | with open("./config.yml.default") as file: 310 | CONFIG = yaml.safe_load(file) 311 | CONFIG["token"] = "" 312 | CONFIG["engine"]["name"] = f"Stockfish{test_suffix}" 313 | CONFIG["engine"]["protocol"] = "homemade" 314 | CONFIG["pgn_directory"] = "TEMP/homemade_game_record" 315 | win = run_bot(CONFIG, logging_level) 316 | logger.info("Finished Testing Homemade") 317 | assert win 318 | assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], 319 | "bo vs b - zzzzzzzz.pgn")) 320 | 321 | 322 | @pytest.mark.timeout(60, method="thread") 323 | def test_buggy_engine() -> None: 324 | """Test lichess-bot with an engine that causes a timeout error within python-chess.""" 325 | with open("./config.yml.default") as file: 326 | CONFIG = yaml.safe_load(file) 327 | CONFIG["token"] = "" 328 | CONFIG["engine"]["dir"] = "test_bot" 329 | 330 | def engine_path(CONFIG: CONFIG_DICT_TYPE) -> str: 331 | directory: str = CONFIG["engine"]["dir"] 332 | name: str = CONFIG["engine"]["name"].removesuffix(".py") 333 | path = os.path.join(directory, name) 334 | if platform == "win32": 335 | path += ".bat" 336 | else: 337 | if platform == "darwin": 338 | path += "_macos" 339 | st = os.stat(path) 340 | os.chmod(path, st.st_mode | stat.S_IEXEC) 341 | return path 342 | 343 | CONFIG["engine"]["name"] = "buggy_engine.py" 344 | CONFIG["engine"]["interpreter"] = "python" if platform == "win32" else "python3" 345 | CONFIG["engine"]["uci_options"] = {"go_commands": {"movetime": 100}} 346 | CONFIG["pgn_directory"] = "TEMP/bug_game_record" 347 | 348 | win = run_bot(CONFIG, logging_level, engine_path(CONFIG)) 349 | logger.info("Finished Testing buggy engine") 350 | assert win 351 | assert os.path.isfile(os.path.join(CONFIG["pgn_directory"], 352 | "bo vs b - zzzzzzzz.pgn")) 353 | -------------------------------------------------------------------------------- /test_bot/test_external_moves.py: -------------------------------------------------------------------------------- 1 | """Test the functions that get the external moves.""" 2 | import backoff 3 | import requests 4 | import yaml 5 | import os 6 | import chess 7 | import logging 8 | import chess.engine 9 | from datetime import timedelta 10 | from copy import deepcopy 11 | from requests.exceptions import ConnectionError as RequestsConnectionError, HTTPError, ReadTimeout, RequestException 12 | from http.client import RemoteDisconnected 13 | from lib.lichess_types import OnlineType, GameEventType 14 | from typing import Optional, Union, cast 15 | from lib.lichess import is_final, backoff_handler, Lichess 16 | from lib.config import Configuration, insert_default_values 17 | from lib.model import Game 18 | from lib.engine_wrapper import get_online_move, get_book_move 19 | 20 | 21 | class MockLichess(Lichess): 22 | """A modified Lichess class for communication with external move sources.""" 23 | 24 | def __init__(self) -> None: 25 | """Initialize only self.other_session and not self.session.""" 26 | self.max_retries = 3 27 | self.other_session = requests.Session() 28 | 29 | def online_book_get(self, path: str, params: Optional[dict[str, Union[str, int]]] = None, 30 | stream: bool = False) -> OnlineType: 31 | """Get an external move from online sources (chessdb or lichess.org).""" 32 | 33 | @backoff.on_exception(backoff.constant, 34 | (RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout), 35 | max_time=60, 36 | max_tries=self.max_retries, 37 | interval=0.1, 38 | giveup=is_final, 39 | on_backoff=backoff_handler, 40 | backoff_log_level=logging.DEBUG, 41 | giveup_log_level=logging.DEBUG) 42 | def online_book_get() -> OnlineType: 43 | json_response: OnlineType = self.other_session.get(path, timeout=2, params=params, stream=stream).json() 44 | return json_response 45 | 46 | return online_book_get() 47 | 48 | def is_website_up(self, url: str) -> bool: 49 | """Check if a website is up.""" 50 | try: 51 | self.other_session.get(url, timeout=2) 52 | return True 53 | except RequestException: 54 | return False 55 | 56 | 57 | def get_configs() -> tuple[Configuration, Configuration, Configuration, Configuration]: 58 | """Create the configs used for the tests.""" 59 | with open("./config.yml.default") as file: 60 | CONFIG = yaml.safe_load(file) 61 | insert_default_values(CONFIG) 62 | CONFIG["engine"]["online_moves"]["lichess_cloud_analysis"]["enabled"] = True 63 | CONFIG["engine"]["online_moves"]["online_egtb"]["enabled"] = True 64 | CONFIG["engine"]["draw_or_resign"]["resign_enabled"] = True 65 | CONFIG["engine"]["polyglot"]["enabled"] = True 66 | CONFIG["engine"]["polyglot"]["book"]["standard"] = ["TEMP/gm2001.bin"] 67 | engine_cfg = Configuration(CONFIG).engine 68 | CONFIG_2 = deepcopy(CONFIG) 69 | CONFIG_2["engine"]["online_moves"]["chessdb_book"]["enabled"] = True 70 | CONFIG_2["engine"]["online_moves"]["online_egtb"]["source"] = "chessdb" 71 | engine_cfg_2 = Configuration(CONFIG_2).engine 72 | return engine_cfg.online_moves, engine_cfg_2.online_moves, engine_cfg.draw_or_resign, engine_cfg.polyglot 73 | 74 | 75 | def get_game() -> Game: 76 | """Create a model.Game to be used in the tests.""" 77 | game_event: GameEventType = {"id": "zzzzzzzz", 78 | "variant": {"key": "standard", 79 | "name": "Standard", 80 | "short": "Std"}, 81 | "clock": {"initial": 60000, 82 | "increment": 2000}, 83 | "speed": "bullet", 84 | "perf": {"name": "Bullet"}, 85 | "rated": True, 86 | "createdAt": 1600000000000, 87 | "white": {"id": "bo", 88 | "name": "bo", 89 | "title": "BOT", 90 | "rating": 3000}, 91 | "black": {"id": "b", 92 | "name": "b", 93 | "title": "BOT", 94 | "rating": 3000, 95 | "provisional": True}, 96 | "initialFen": "startpos", 97 | "type": "gameFull", 98 | "state": {"type": "gameState", 99 | "moves": "", 100 | "wtime": 1000000, 101 | "btime": 1000000, 102 | "winc": 2000, 103 | "binc": 2000, 104 | "status": "started"}} 105 | return Game(game_event, "b", "https://lichess.org", timedelta(seconds=60)) 106 | 107 | 108 | def download_opening_book() -> None: 109 | """Download gm2001.bin.""" 110 | if os.path.exists("./TEMP/gm2001.bin"): 111 | return 112 | response = requests.get("https://github.com/gmcheems-org/free-opening-books/raw/main/books/bin/gm2001.bin", 113 | allow_redirects=True) 114 | with open("./TEMP/gm2001.bin", "wb") as file: 115 | file.write(response.content) 116 | 117 | 118 | os.makedirs("TEMP", exist_ok=True) 119 | 120 | 121 | def get_online_move_wrapper(li: Lichess, board: chess.Board, game: Game, online_moves_cfg: Configuration, 122 | draw_or_resign_cfg: Configuration) -> chess.engine.PlayResult: 123 | """Wrap `lib.engine_wrapper.get_online_move` so that it only returns a PlayResult type.""" 124 | return cast(chess.engine.PlayResult, get_online_move(li, board, game, online_moves_cfg, draw_or_resign_cfg)) 125 | 126 | 127 | def test_external_moves() -> None: 128 | """Test that the code for external moves works properly.""" 129 | li = MockLichess() 130 | game = get_game() 131 | download_opening_book() 132 | online_cfg, online_cfg_2, draw_or_resign_cfg, polyglot_cfg = get_configs() 133 | 134 | starting_fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" 135 | opening_fen = "rn1q1rk1/pbp1bpp1/1p2pn1p/3p4/2PP3B/2N1PN2/PP2BPPP/R2QK2R w KQ - 2 9" 136 | middlegame_fen = "8/5p2/1n1p1nk1/1p1Pp1p1/1Pp1P1Pp/r1P2B1P/2RNKP2/8 w - - 0 31" 137 | endgame_wdl2_fen = "2k5/4n2Q/5N2/8/8/8/1r6/2K5 b - - 0 123" 138 | endgame_wdl1_fen = "6N1/3n4/3k1b2/8/8/7Q/1r6/5K2 b - - 6 9" 139 | endgame_wdl0_fen = "6N1/3n4/3k1b2/8/8/7Q/5K2/1r6 b - - 8 10" 140 | 141 | is_lichess_org_up = li.is_website_up("https://lichess.org/api/cloud-eval") 142 | is_lichess_ovh_up = li.is_website_up("https://tablebase.lichess.ovh/standard") 143 | is_chessdb_cn_up = li.is_website_up("https://www.chessdb.cn/cdb.php") 144 | 145 | # Test lichess_cloud_analysis. 146 | if is_lichess_org_up: 147 | assert get_online_move_wrapper(li, chess.Board(starting_fen), game, online_cfg, draw_or_resign_cfg).move is not None 148 | assert get_online_move_wrapper(li, chess.Board(opening_fen), game, online_cfg, draw_or_resign_cfg).move is not None 149 | assert get_online_move_wrapper(li, chess.Board(middlegame_fen), game, online_cfg, draw_or_resign_cfg).move is None 150 | 151 | # Test chessdb_book. 152 | if is_chessdb_cn_up: 153 | assert get_online_move_wrapper(li, chess.Board(starting_fen), game, online_cfg_2, draw_or_resign_cfg).move is not None 154 | assert get_online_move_wrapper(li, chess.Board(opening_fen), game, online_cfg_2, draw_or_resign_cfg).move is not None 155 | assert get_online_move_wrapper(li, chess.Board(middlegame_fen), game, online_cfg_2, draw_or_resign_cfg).move is None 156 | 157 | # Test online_egtb with lichess. 158 | if is_lichess_ovh_up: 159 | assert get_online_move_wrapper(li, chess.Board(endgame_wdl2_fen), game, online_cfg, draw_or_resign_cfg).resigned 160 | assert get_online_move_wrapper(li, chess.Board(endgame_wdl0_fen), game, online_cfg, draw_or_resign_cfg).draw_offered 161 | wdl1_move = get_online_move_wrapper(li, chess.Board(endgame_wdl1_fen), game, online_cfg, draw_or_resign_cfg) 162 | assert not wdl1_move.resigned and not wdl1_move.draw_offered 163 | # Test with reversed colors. 164 | assert get_online_move_wrapper(li, chess.Board(endgame_wdl2_fen).mirror(), game, online_cfg, 165 | draw_or_resign_cfg).resigned 166 | assert get_online_move_wrapper(li, chess.Board(endgame_wdl0_fen).mirror(), game, online_cfg, 167 | draw_or_resign_cfg).draw_offered 168 | wdl1_move = get_online_move_wrapper(li, chess.Board(endgame_wdl1_fen).mirror(), game, online_cfg, draw_or_resign_cfg) 169 | assert not wdl1_move.resigned and not wdl1_move.draw_offered 170 | 171 | # Test online_egtb with chessdb. 172 | if is_chessdb_cn_up: 173 | assert get_online_move_wrapper(li, chess.Board(endgame_wdl2_fen), game, online_cfg_2, draw_or_resign_cfg).resigned 174 | assert get_online_move_wrapper(li, chess.Board(endgame_wdl0_fen), game, online_cfg_2, draw_or_resign_cfg).draw_offered 175 | wdl1_move = get_online_move_wrapper(li, chess.Board(endgame_wdl1_fen), game, online_cfg_2, draw_or_resign_cfg) 176 | assert not wdl1_move.resigned and not wdl1_move.draw_offered 177 | # Test with reversed colors. 178 | assert get_online_move_wrapper(li, chess.Board(endgame_wdl2_fen).mirror(), game, online_cfg_2, 179 | draw_or_resign_cfg).resigned 180 | assert get_online_move_wrapper(li, chess.Board(endgame_wdl0_fen).mirror(), game, online_cfg_2, 181 | draw_or_resign_cfg).draw_offered 182 | wdl1_move = get_online_move_wrapper(li, chess.Board(endgame_wdl1_fen).mirror(), game, online_cfg_2, draw_or_resign_cfg) 183 | assert not wdl1_move.resigned and not wdl1_move.draw_offered 184 | 185 | # Test opening book. 186 | assert get_book_move(chess.Board(opening_fen), game, polyglot_cfg).move == chess.Move.from_uci("h4f6") 187 | -------------------------------------------------------------------------------- /test_bot/test_lichess.py: -------------------------------------------------------------------------------- 1 | """Tests for the lichess communication.""" 2 | 3 | from lib import lichess 4 | import logging 5 | import os 6 | import pytest 7 | 8 | 9 | def test_lichess() -> None: 10 | """Test the lichess communication.""" 11 | token = os.environ.get("LICHESS_BOT_TEST_TOKEN") 12 | if not token: 13 | pytest.skip("Lichess-bot test token must be set.") 14 | li = lichess.Lichess(token, "https://lichess.org/", "0.0.0", logging.DEBUG, 3) 15 | assert len(li.get_online_bots()) > 20 16 | profile = li.get_profile() 17 | profile["seenAt"] = 1700000000000 18 | assert profile == {"blocking": False, 19 | "count": {"ai": 3, "all": 12, "bookmark": 0, "draw": 1, "drawH": 1, "import": 0, 20 | "loss": 8, "lossH": 5, "me": 0, "playing": 0, "rated": 0, "win": 3, "winH": 3}, 21 | "createdAt": 1627834995597, "followable": True, "following": False, "id": "badsunfish", 22 | "perfs": {"blitz": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 500}, 23 | "bullet": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 500}, 24 | "classical": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 500}, 25 | "correspondence": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 500}, 26 | "rapid": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 500}}, 27 | "playTime": {"total": 1873, "tv": 0}, "seenAt": 1700000000000, "title": "BOT", 28 | "url": "https://lichess.org/@/BadSunfish", "username": "BadSunfish"} 29 | assert li.get_ongoing_games() == [] 30 | assert li.is_online("NNWithSF") is False 31 | public_data = li.get_public_data("lichapibot") 32 | for key in public_data["perfs"]: 33 | public_data["perfs"][key]["rd"] = 0 34 | assert public_data == {"blocking": False, "count": {"ai": 1, "all": 15774, "bookmark": 0, "draw": 3009, "drawH": 3009, 35 | "import": 0, "loss": 6423, "lossH": 6423, 36 | "me": 0, "playing": 0, "rated": 15121, "win": 6342, "winH": 6341}, 37 | "createdAt": 1524037267522, "followable": True, "following": False, "id": "lichapibot", 38 | "perfs": {"blitz": {"games": 2430, "prog": 3, "prov": True, "rating": 2388, "rd": 0}, 39 | "bullet": {"games": 7293, "prog": 9, "prov": True, "rating": 2298, "rd": 0}, 40 | "classical": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 0}, 41 | "correspondence": {"games": 0, "prog": 0, "prov": True, "rating": 1500, "rd": 0}, 42 | "rapid": {"games": 993, "prog": -80, "prov": True, "rating": 2363, "rd": 0}}, 43 | "playTime": {"total": 4111502, "tv": 1582068}, "profile": {}, 44 | "seenAt": 1669272254317, "title": "BOT", "tosViolation": True, 45 | "url": "https://lichess.org/@/lichapibot", "username": "lichapibot"} 46 | -------------------------------------------------------------------------------- /test_bot/test_model.py: -------------------------------------------------------------------------------- 1 | """Tests for the models.""" 2 | 3 | import datetime 4 | from lib import model 5 | import yaml 6 | from lib import config 7 | from collections import defaultdict, Counter 8 | from lib.timer import Timer 9 | from lib.lichess_types import ChallengeType, UserProfileType, GameEventType, PlayerType 10 | 11 | 12 | def test_challenge() -> None: 13 | """Test the challenge model.""" 14 | challenge: ChallengeType = {"id": "zzzzzzzz", "url": "https://lichess.org/zzzzzzzz", "status": "created", 15 | "challenger": {"id": "c", "name": "c", "rating": 2000, "title": None, "online": True}, 16 | "destUser": {"id": "b", "name": "b", "rating": 3000, "title": "BOT", "online": True}, 17 | "variant": {"key": "standard", "name": "Standard", "short": "Std"}, "rated": False, 18 | "speed": "bullet", 19 | "timeControl": {"type": "clock", "limit": 90, "increment": 1, "show": "1.5+1"}, 20 | "color": "random", "finalColor": "white", "perf": {"icon": "\ue032", "name": "Bullet"}} 21 | user_profile: UserProfileType = {"id": "b", "username": "b", 22 | "perfs": {"bullet": {"games": 100, "rating": 3000, "rd": 150, "prog": -10}, 23 | "blitz": {"games": 100, "rating": 3000, "rd": 150, "prog": -10}, 24 | "rapid": {"games": 100, "rating": 3000, "rd": 150, "prog": -10}, 25 | "classical": {"games": 100, "rating": 3000, "rd": 150, "prog": -10}, 26 | "correspondence": {"games": 100, "rating": 3000, "rd": 150, "prog": -10}, 27 | "antichess": {"games": 100, "rating": 3000, "rd": 150, "prog": -10, 28 | "prov": True}}, 29 | "title": "BOT", "createdAt": 1500000000000, 30 | "profile": {"bio": "This is my bio", 31 | "links": "https://github.com/lichess-bot-devs/lichess-bot"}, 32 | "seenAt": 1700000000000, "playTime": {"total": 1000000, "tv": 10000}, 33 | "url": "https://lichess.org/@/b", 34 | "count": {"all": 600, "rated": 500, "ai": 50, "draw": 200, "drawH": 50, "loss": 50, 35 | "lossH": 50, "win": 250, "winH": 200, "bookmark": 0, "playing": 0, 36 | "import": 0, "me": 0}, 37 | "followable": True, "following": False, "blocking": False} 38 | 39 | with open("./config.yml.default") as file: 40 | CONFIG = yaml.safe_load(file) 41 | CONFIG["token"] = "" 42 | CONFIG["challenge"]["allow_list"] = [] 43 | CONFIG["challenge"]["block_list"] = [] 44 | configuration = config.Configuration(CONFIG).challenge 45 | recent_challenges: defaultdict[str, list[Timer]] = defaultdict() 46 | recent_challenges["c"] = [] 47 | 48 | challenge_model = model.Challenge(challenge, user_profile) 49 | assert challenge_model.id == "zzzzzzzz" 50 | assert challenge_model.rated is False 51 | assert challenge_model.variant == "standard" 52 | assert challenge_model.speed == "bullet" 53 | assert challenge_model.time_control["show"] == "1.5+1" 54 | assert challenge_model.color == "white" 55 | assert challenge_model.is_supported(configuration, recent_challenges, Counter()) == (True, "") 56 | 57 | CONFIG["challenge"]["min_base"] = 120 58 | assert challenge_model.is_supported(configuration, recent_challenges, Counter()) == (False, "timeControl") 59 | 60 | 61 | def test_game() -> None: 62 | """Test the game model.""" 63 | game: GameEventType = {"id": "zzzzzzzz", "variant": {"key": "standard", "name": "Standard", "short": "Std"}, 64 | "speed": "bullet", "perf": {"name": "Bullet"}, "rated": False, "createdAt": 1700000000000, 65 | "white": {"id": "c", "name": "c", "title": None, "rating": 2000}, 66 | "black": {"id": "b", "name": "b", "title": "BOT", "rating": 3000}, 67 | "initialFen": "startpos", "clock": {"initial": 90000, "increment": 1000}, "type": "gameFull", 68 | "state": {"type": "gameState", "moves": "", "wtime": 90000, "btime": 90000, "winc": 1000, 69 | "binc": 1000, "status": "started"}} 70 | username = "b" 71 | base_url = "https://lichess.org/" 72 | abort_time = datetime.timedelta(seconds=30) 73 | 74 | game_model = model.Game(game, username, base_url, abort_time) 75 | assert game_model.id == "zzzzzzzz" 76 | assert game_model.mode == "casual" 77 | assert game_model.is_white is False 78 | 79 | 80 | def test_player() -> None: 81 | """Test the player model.""" 82 | player: PlayerType = {"id": "b", "name": "b", "rating": 3000, "title": "BOT", "online": True} 83 | player_model = model.Player(player) 84 | assert player_model.is_bot is True 85 | assert str(player_model) == "BOT b (3000)" 86 | -------------------------------------------------------------------------------- /test_bot/test_timer.py: -------------------------------------------------------------------------------- 1 | """Test functions dedicated to time measurement and conversion.""" 2 | 3 | from datetime import datetime, timedelta 4 | 5 | from lib import timer 6 | 7 | 8 | def test_time_conversion() -> None: 9 | """Test conversion of time units.""" 10 | assert timer.msec(1000) == timedelta(milliseconds=1000) 11 | assert timer.to_msec(timedelta(milliseconds=1000)) == 1000 12 | 13 | assert timer.msec_str(timedelta(milliseconds=1000)) == "1000" 14 | 15 | assert timer.seconds(1) == timedelta(seconds=1) 16 | assert timer.to_seconds(timedelta(seconds=1)) == 1 17 | 18 | assert timer.sec_str(timedelta(seconds=1)) == "1" 19 | 20 | assert timer.minutes(1) == timedelta(minutes=1) 21 | assert timer.hours(1) == timedelta(hours=1) 22 | assert timer.days(1) == timedelta(days=1) 23 | assert timer.years(1) == timedelta(days=365) 24 | 25 | assert timer.to_msec(timer.seconds(1)) == 1000 26 | assert timer.to_seconds(timer.minutes(1)) == 60 27 | assert timer.to_seconds(timer.hours(1)) == 60*60 28 | assert timer.to_seconds(timer.days(1)) == 24*60*60 29 | assert timer.to_seconds(timer.years(1)) == 365*24*60*60 30 | 31 | 32 | def test_init() -> None: 33 | """Test Timer class init.""" 34 | t = timer.Timer() 35 | assert t.duration == timedelta(0) 36 | assert t.starting_time is not None 37 | 38 | duration = timedelta(seconds=10) 39 | t = timer.Timer(duration) 40 | assert t.duration == duration 41 | assert t.starting_time is not None 42 | 43 | backdated_timestamp = datetime.now() - timedelta(seconds=10) 44 | t = timer.Timer(backdated_timestamp=backdated_timestamp) 45 | assert t.starting_time is not None 46 | assert t.time_since_reset() >= timedelta(seconds=10) 47 | 48 | def test_is_expired() -> None: 49 | """Test timer expiration.""" 50 | t = timer.Timer(timedelta(seconds=10)) 51 | assert not t.is_expired() 52 | 53 | t = timer.Timer(timedelta(seconds=0)) 54 | assert t.is_expired() 55 | 56 | t = timer.Timer(timedelta(seconds=10)) 57 | t.reset() 58 | t.starting_time -= 10 59 | assert t.is_expired() 60 | 61 | def test_reset() -> None: 62 | """Test timer reset.""" 63 | t = timer.Timer(timedelta(seconds=10)) 64 | t.reset() 65 | assert t.starting_time is not None 66 | assert timer.sec_str(t.time_since_reset()) == timer.sec_str(timedelta(0)) 67 | 68 | def test_time() -> None: 69 | """Test time measurement, expiration, and time until expiration.""" 70 | t = timer.Timer(timedelta(seconds=10)) 71 | t.starting_time -= 5 72 | assert timer.sec_str(t.time_since_reset()) == timer.sec_str(timedelta(seconds=5)) 73 | 74 | t = timer.Timer(timedelta(seconds=10)) 75 | t.starting_time -= 5 76 | assert timer.sec_str(t.time_until_expiration()) == timer.sec_str(timedelta(seconds=5)) 77 | 78 | t = timer.Timer(timedelta(seconds=10)) 79 | t.starting_time -= 15 # Simulate time passing 80 | assert t.time_until_expiration() == timedelta(0) 81 | 82 | t = timer.Timer(timedelta(seconds=10)) 83 | t.starting_time -= 15 84 | assert t.time_until_expiration() == timedelta(0) 85 | 86 | t = timer.Timer(timedelta(seconds=10)) 87 | t.starting_time -= 5 88 | assert timer.sec_str(t.time_until_expiration()) == timer.sec_str(timedelta(seconds=5)) 89 | 90 | def test_starting_timestamp() -> None: 91 | """Test timestamp conversion and integration.""" 92 | t = timer.Timer(timedelta(seconds=10)) 93 | timestamp_format = "%Y-%m-%d %H:%M:%S" 94 | expected_timestamp = (datetime.now() - t.time_since_reset()).strftime(timestamp_format) 95 | assert t.starting_timestamp(timestamp_format) == expected_timestamp 96 | -------------------------------------------------------------------------------- /wiki/Create-a-homemade-engine.md: -------------------------------------------------------------------------------- 1 | ## Creating a homemade engine 2 | As an alternative to creating an entire chess engine and implementing one of the communication protocols (`UCI` or `XBoard`), a bot can also be created by writing a single class with a single method. The `search()` method in this new class takes the current board and the game clock as arguments and should return a move based on whatever criteria the coder desires. 3 | 4 | Steps to create a homemade bot: 5 | 6 | 1. Do all the steps in the [How to Install](#how-to-install) 7 | 2. In the `config.yml`, change the engine protocol to `homemade` 8 | 3. Create a class in `homemade.py` that extends `MinimalEngine`. 9 | 4. Create a method called `search()` with an argument `board` that chooses a legal move from the board. 10 | - There are examples in `homemade.py` to help you get started. These examples show different ways to implement the `search()` method and the proper way to return the chosen move. 11 | 5. In the `config.yml`, change the name from `engine_name` to the name of your class 12 | - In this case, you could change it to: 13 | `name: "RandomMove"` 14 | -------------------------------------------------------------------------------- /wiki/Extra-customizations.md: -------------------------------------------------------------------------------- 1 | ## Extra customizations 2 | 3 | If your bot has more complex requirements than can be expressed in the configuration file, edit the file named `extra_game_handlers.py` in the main lichess-bot directory. 4 | Within this file, write whatever code is needed. 5 | 6 | Each section below describes a customization. 7 | Only one function is needed to make that customization work. 8 | However, if writing other functions makes implementing the customization easier, do so. 9 | Only the named function will be used in lichess-bot. 10 | 11 | 12 | ### Filtering challenges 13 | 14 | The function `is_supported_extra()` allows for finer control over which challenges from other players are accepted. 15 | It should use the data in the `Challenge` argument (see `lib/model.py`) and return `True` to accept the challenge or `False` to reject it. 16 | As an example, here's a version that will only only accept games where the bot plays black: 17 | ``` python 18 | def is_supported_extra(challenge): 19 | return challenge.color == "white" 20 | ``` 21 | For another example, this function will reject any board that contains queens: 22 | ``` python 23 | def is_supported_extra(challenge): 24 | # https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation 25 | starting_position = challenge.initial_fen 26 | return starting_position != "startpos" and "Q" not in starting_position.upper() 27 | ``` 28 | The body of the function can be as complex as needed and combine any conditions to suit the needs of the bot. 29 | Information within the `Challenge` instance is detailed in the [Lichess API documentation](https://lichess.org/api#tag/Bot/operation/apiStreamEvent) (click on 200 under Responses, then select `ChallengeEvent` under Response Schema and expand the `challenge` heading). 30 | 31 | ### Tailoring engine options 32 | 33 | The function `game_specific_options()` can modify the engine options for UCI and XBoard engines based on aspects of the game about to be played. 34 | It use the data in the `Game` argument (see `lib/model.py`) and return a dictionary of `str` to values. 35 | This dictionary will add or replace values in the `uci_options` or `xboard_options` section of the bot's configuration file. 36 | For example, this version of the function will changes the move overhead value for longer games: 37 | ``` python 38 | from datetime import timedelta 39 | 40 | def game_specific_options(game): 41 | if game.clock_initial >= timedelta(minutes=5): 42 | return {"Move Overhead": 5000} 43 | else: 44 | return {} 45 | ``` 46 | Returning an empty dictionary leaves the engine options unchanged. 47 | -------------------------------------------------------------------------------- /wiki/How-to-Install.md: -------------------------------------------------------------------------------- 1 | ### Linux 2 | - **NOTE: Only Python 3.9 or later is supported!** 3 | - Download the repo into lichess-bot directory. 4 | - Navigate to the directory in cmd/Terminal: `cd lichess-bot`. 5 | - Install dependencies: `apt install python3 python3-pip python3-virtualenv python3-venv`. 6 | - In non-Ubuntu linux distros, replace `apt` with the correct package manager (`pacman` in Arch, `dnf` in Fedora, etc.), package name, and installation command. 7 | - Run the following commands to set up a virtual environment: 8 | ``` 9 | python3 -m venv venv # If this fails you probably need to add Python3 to your PATH. 10 | virtualenv venv -p python3 11 | source ./venv/bin/activate 12 | python3 -m pip install -r requirements.txt 13 | ``` 14 | - Copy `config.yml.default` to `config.yml`. 15 | 16 | **Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) 17 | 18 | ### Mac/BSD 19 | - **NOTE: Only Python 3.9 or later is supported!** 20 | - Install Python and other dependencies using the [homebrew package manager](https://brew.sh/): 21 | - ` brew install python3 virtualenv # Net-/FreeBSD users might want to install: git, python311, py311-pip and py311-virtualenv.` 22 | - Download the repo into lichess-bot directory. 23 | - Navigate to the directory in cmd/Terminal: `cd lichess-bot`. 24 | ``` 25 | python3 -m venv venv # If this fails you probably need to add Python3 to your PATH. 26 | virtualenv venv -p python3 27 | . venv/bin/activate 28 | python3 -m pip install -r requirements.txt 29 | ``` 30 | - Copy `config.yml.default` to `config.yml`. 31 | 32 | **Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) 33 | 34 | ### Windows 35 | - **NOTE: Only Python 3.9 or later is supported!** 36 | - If needed, install Python: 37 | - [Download Python here](https://www.python.org/downloads/). 38 | - When installing, enable "add Python to PATH". 39 | - If the Python version is at least 3.10, a default local install works. 40 | - If the Python version is 3.9, choose "Custom installation", keep the defaults on the Optional Features page, and choose "Install for all users" in the Advanced Options page. 41 | - Start Terminal, PowerShell, cmd, or your preferred command prompt. 42 | - Upgrade pip: `py -m pip install --upgrade pip`. 43 | - Download the repo into lichess-bot directory. 44 | - Navigate to the directory: `cd [folder's address]` (for example, `cd C:\Users\username\repos\lichess-bot`). 45 | - Install virtualenv: `py -m pip install virtualenv`. 46 | - Setup virtualenv: 47 | ``` 48 | py -m venv venv # If this fails you probably need to add Python3 to your PATH. 49 | venv\Scripts\activate 50 | pip install -r requirements.txt 51 | ``` 52 | PowerShell note: If the `activate` command does not work in PowerShell, execute `Set-ExecutionPolicy RemoteSigned` first and choose `Y` there (you may need to run Powershell as administrator). After you execute the script, change execution policy back with `Set-ExecutionPolicy Restricted` and pressing `Y`. 53 | - Copy `config.yml.default` to `config.yml`. 54 | 55 | **Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) 56 | 57 | ### Docker 58 | If you have a [Docker](https://www.docker.com/) host, you can use the ```lichess-bot-devs/lichess-bot``` [image in DockerHub](https://hub.docker.com/r/lichessbotdevs/lichess-bot). 59 | It requires a folder where you have to copy `config.yml.default` to `config.yml`. 60 | 61 | See [Running with Docker](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-use-the-Docker-image) once you've created the OAuth token and setup the engine. 62 | 63 | **Next step**: [Create a Lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) 64 | -------------------------------------------------------------------------------- /wiki/How-to-Run-lichess‐bot.md: -------------------------------------------------------------------------------- 1 | ## Running lichess-bot 2 | After activating the virtual environment created in the installation steps (the `source` line for Linux and Macs or the `activate` script for Windows), run 3 | ``` 4 | python3 lichess-bot.py 5 | ``` 6 | The working directory for the engine execution will be the lichess-bot directory. If your engine requires files located elsewhere, make sure they are specified by absolute path or copy the files to an appropriate location inside the lichess-bot directory. 7 | 8 | To output more information (including your engine's thinking output and debugging information), the `-v` option can be passed to lichess-bot: 9 | ``` 10 | python3 lichess-bot.py -v 11 | ``` 12 | 13 | If you want to disable automatic logging: 14 | ``` 15 | python3 lichess-bot.py --disable_auto_logging 16 | ``` 17 | 18 | If you want to record the output to a log file, add the `-l` or `--logfile` along with a file name: 19 | ``` 20 | python3 lichess-bot.py --logfile log.txt 21 | ``` 22 | 23 | If you want to specify a different config file, add the `--config` along with a file name: 24 | ``` 25 | python3 lichess-bot.py --config config2.yml 26 | ``` 27 | 28 | ### Running as a service 29 | - Here's an example systemd service definition: 30 | ```ini 31 | [Unit] 32 | Description=lichess-bot 33 | After=network-online.target 34 | Wants=network-online.target 35 | 36 | [Service] 37 | Environment="PYTHONUNBUFFERED=1" 38 | ExecStart=/usr/bin/python3 /home/thibault/lichess-bot/lichess-bot.py 39 | WorkingDirectory=/home/thibault/lichess-bot/ 40 | User=thibault 41 | Group=thibault 42 | Restart=always 43 | 44 | [Install] 45 | WantedBy=multi-user.target 46 | ``` 47 | 48 | ## Quitting 49 | - Press `CTRL+C`. 50 | - If `quit_after_all_games_finish` is set to `true` in your config file, lichess-bot will wait for all games to exit. Otherwise, all games will exit immediately. 51 | - It may take several seconds for lichess-bot to quit once all games have exited. 52 | 53 | **Previous step**: [Upgrade to a BOT account](https://github.com/lichess-bot-devs/lichess-bot/wiki/Upgrade-to-a-BOT-account) 54 | -------------------------------------------------------------------------------- /wiki/How-to-create-a-Lichess-OAuth-token.md: -------------------------------------------------------------------------------- 1 | # Creating a lichess OAuth token 2 | - Create an account for your bot on [Lichess.org](https://lichess.org/signup). 3 | - **NOTE: If you have previously played games on an existing account, you will not be able to use it as a bot account.** 4 | - Once your account has been created and you are logged in, [create a personal OAuth2 token with the "Play games with the bot API" (`bot:play`) scope](https://lichess.org/account/oauth/token/create?scopes[]=bot:play&description=lichess-bot) selected and a description added. 5 | - A `token` (e.g. `xxxxxxxxxxxxxxxx`) will be displayed. Store this in the `config.yml` file as the `token` field. You can also set the token in the environment variable `$LICHESS_BOT_TOKEN`. 6 | - **NOTE: You won't see this token again on Lichess, so do save it.** 7 | 8 | **Next step**: [Setup the engine](https://github.com/lichess-bot-devs/lichess-bot/wiki/Setup-the-engine) 9 | 10 | **Previous step**: [Install lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Install) 11 | -------------------------------------------------------------------------------- /wiki/How-to-use-the-Docker-image.md: -------------------------------------------------------------------------------- 1 | # How to launch your bot 2 | ## Download the docker image 3 | The images are available through [Docker hub](https://hub.docker.com/r/lichessbotdevs/lichess-bot) and [Github container registry](https://github.com/lichess-bot-devs/lichess-bot/pkgs/container/lichess-bot). 4 | Run `docker pull lichessbotdevs/lichess-bot` to download from docker hub, or run `docker pull ghcr.io/lichess-bot-devs/lichess-bot` if you want to download it from the Github container registry. 5 | 6 | **NOTE**: On Docker hub, the organization name is `lichessbotdevs` (without hyphens) while on the Github container registry it is `lichess-bot-devs`. The package name is in both cases `lichess-bot` (with hyphens). 7 | 8 | In the steps below replace `lichessbotdevs` with `ghcr.io/lichess-bot-devs` if you downloaded the image through the Github container registry. 9 | 10 | ## Prepare the deployment 11 | Create a folder where you will put your configuration file, the UCI/XBoard program that runs your engine (be aware that this program will run inside the container ... in a Linux OS) or your own [`homemade.py`](https://github.com/lichess-bot-devs/lichess-bot/wiki/Create-a-homemade-engine) and, if required, your own [`extra_game_handlers.py`](https://github.com/lichess-bot-devs/lichess-bot/wiki/Extra-customizations). 12 | 13 | The configuration file **must** be named `config.yml`. 14 | 15 | You can see an example of this file using the following command: 16 | ```docker run --rm --entrypoint=cat lichessbotdevs/lichess-bot config.yml.default```. 17 | 18 | You can also find documentation [here](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot). 19 | 20 | ## Run the bot 21 | 22 | Once your configuration files are ready, let's say in `/home/me/myEngine` folder, run the following command: 23 | ```docker run -d -v /home/me/myEngine:/lichess-bot/config --name myBot lichessbotdevs/lichess-bot``` 24 | 25 | **NOTE**: Prefer using absolute paths over relative paths, as relative ones may cause problems. 26 | 27 | That's all! 28 | 29 | ### Warning: 30 | - Make sure you've set the `dir` and `working_dir` attributes of your `config.yml` to the right path (remember the engine runs in the container's OS, so the path should start with `/lichess-bot/config/`). 31 | - **If you've configured a folder to save pgn files** using [`pgn_directory`](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot#other-options) that is not in `/lichess-bot/config` directory, always mount a volume to that folder. Without that, your saved games will remain unreachable from the outside of the container, and storing a lot of games in the container's file system could result in disk saturation. 32 | - The container uses the standard docker logging system and the bot is always launched with the `--disable_auto_logging` option. 33 | Use the `docker logs myBot` [command](https://docs.docker.com/reference/cli/docker/container/logs/) to access to the bot logs. 34 | 35 | ## Stop and delete the bot 36 | Use the standard commands. 37 | If you used the command from the [Run chapter](#run-the-bot) above: ```docker stop myBot``` and ```docker rm myBot``` 38 | 39 | # Image variants 40 | 41 | The `lichess-bot` images come in two flavors, each designed for a specific use case. 42 | 43 | ## lichess-bot:\ 44 | This is the defacto image. It is based on the [`python:3`](https://hub.docker.com/_/python) image. 45 | If you are unsure about what your needs are, you probably want to use this one. 46 | 47 | To download the latest version use `lichess-bot:latest`. 48 | 49 | ## lichess-bot:\-alpine 50 | This image is based on the popular Alpine Linux project, available in the alpine official image. Alpine Linux is much smaller than most distribution base images, and thus leads to a much slimmer image than the default one (80MB instead of 1GB). 51 | 52 | This variant is useful when final image size being as small as possible is your primary concern. The main caveat to note is that it does use musl libc instead of glibc and friends, so software will often run into issues depending on the depth of their libc requirements/assumptions. For instance, running [Stockfish](https://stockfishchess.org/) on this image requires extra libraries installation. 53 | 54 | To download the latest version use `lichess-bot:alpine`. 55 | 56 | # Some tips 57 | 58 | ## What if my engine requires some software installation? 59 | You will have to create a new Docker image of your own and install the required software in your `Dockerfile`. 60 | For example to install java 17, the docker file would look like: 61 | ``` 62 | FROM lichessbotdevs/lichess-bot:alpine 63 | 64 | RUN apk add --no-cache openjdk17-jre 65 | ``` 66 | Please note that, as `lichess-bot:alpine` image is based on [Alpine](https://www.alpinelinux.org/), you'll have to install new software using the ```apk``` command. 67 | 68 | ## What if I want to add options to ```lichess-bot.py```? 69 | 70 | If you want to pass some options to the ```lichess-bot.py``` executed in the container, add them in the ```OPTIONS``` environment variable. 71 | For instance, to launch the bot in verbose mode, run the command: 72 | ```docker run -d -v /home/me/myEngine:/lichess-bot/config --env OPTIONS=-v lichessbotdevs/lichess-bot``` 73 | 74 | ## How to know which release of lichess-bot is running? 75 | Use the following command: ```docker run --rm --entrypoint=cat lichessbotdevs/lichess-bot lib/versioning.yml``` 76 | -------------------------------------------------------------------------------- /wiki/Setup-the-engine.md: -------------------------------------------------------------------------------- 1 | # Setup the engine 2 | ## For all engines 3 | Within the file `config.yml`: 4 | - Enter the directory containing the engine executable in the `engine: dir` field. 5 | - Enter the executable name in the `engine: name` field (In Windows you may need to type a name with ".exe", like "lczero.exe") 6 | - If you want the engine to run in a different directory (e.g., if the engine needs to read or write files at a certain location), enter that directory in the `engine: working_dir` field. 7 | - If this field is blank or missing, the current directory will be used. 8 | - IMPORTANT NOTE: If this field is used, the running engine will look for files and directories (Syzygy tablebases, for example) relative to this path, not the directory where lichess-bot was launched. Files and folders specified with absolute paths are unaffected. 9 | 10 | As an optional convenience, there is a folder named `engines` within the lichess-bot folder where you can copy your engine and all the files it needs. This is the default executable location in the `config.yml.default` file. 11 | 12 | ## For Leela Chess Zero 13 | ### LeelaChessZero: Mac/Linux 14 | - Download the weights for the id you want to play from [here](https://lczero.org/play/networks/bestnets/). 15 | - Extract the weights from the zip archive and rename it to `latest.txt`. 16 | - For Mac/Linux, build the lczero binary yourself following [LeelaChessZero/lc0/README](https://github.com/LeelaChessZero/lc0/blob/master/README.md). 17 | - Copy both the files into the `engine.dir` directory. 18 | - Change the `engine.name` and `engine.engine_options.weights` keys in `config.yml` file to `lczero` and `weights.pb.gz`. 19 | - You can specify the number of `engine.uci_options.threads` in the `config.yml` file as well. 20 | - To start: `python3 lichess-bot.py`. 21 | 22 | ### LeelaChessZero: Windows CPU 2021 23 | - For Windows modern CPUs, download the lczero binary from the [latest Lc0 release](https://github.com/LeelaChessZero/lc0/releases) (e.g. `lc0-v0.27.0-windows-cpu-dnnl.zip`). 24 | - Unzip the file, it comes with `lc0.exe` , `dnnl.dll`, and a weights file example, `703810.pb.gz` (amongst other files). 25 | - All three main files need to be copied to the `engines` directory. 26 | - The `lc0.exe` should be doubleclicked and the windows safesearch warning about it being unsigned should be cleared (be careful and be sure you have the genuine file). 27 | - Change the `engine.name` key in the `config.yml` file to `lc0.exe`, no need to edit the `config.yml` file concerning the weights file as the `lc0.exe` will use whatever `*.pb.gz` is in the same folder (have only one `*pb.gz` file in the `engines` directory). 28 | - To start: `python3 lichess-bot.py`. 29 | 30 | **Next step**: [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot) 31 | 32 | **Previous step**: [Create a lichess OAuth token](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-create-a-Lichess-OAuth-token) 33 | -------------------------------------------------------------------------------- /wiki/Upgrade-to-a-BOT-account.md: -------------------------------------------------------------------------------- 1 | # Upgrading to a BOT account 2 | **WARNING: This is irreversible. [Read more about upgrading to bot account](https://lichess.org/api#operation/botAccountUpgrade).** 3 | - run `python3 lichess-bot.py -u`. 4 | 5 | If successful, this step will start a lichess session and the bot will start playing games. In the future, only the commands in the next step will have to be run. 6 | 7 | **Next step**: [Run lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/How-to-Run-lichess%E2%80%90bot) 8 | 9 | **Previous step**: [Configure lichess-bot](https://github.com/lichess-bot-devs/lichess-bot/wiki/Configure-lichess-bot) 10 | --------------------------------------------------------------------------------