BlueMoonBot commited on
Commit
f56e4d7
·
verified ·
1 Parent(s): 539d943

Upload 67 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. lichess-bot-master/.gitattributes +2 -0
  2. lichess-bot-master/.github/ISSUE_TEMPLATE/bug_report.md +30 -0
  3. lichess-bot-master/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  4. lichess-bot-master/.github/dependabot.yml +17 -0
  5. lichess-bot-master/.github/pull_request_template.md +20 -0
  6. lichess-bot-master/.github/workflows/docker.yml +85 -0
  7. lichess-bot-master/.github/workflows/mypy.yml +32 -0
  8. lichess-bot-master/.github/workflows/python-build.yml +34 -0
  9. lichess-bot-master/.github/workflows/python-test.yml +50 -0
  10. lichess-bot-master/.github/workflows/sync-wiki.yml +34 -0
  11. lichess-bot-master/.github/workflows/update_version.py +29 -0
  12. lichess-bot-master/.github/workflows/versioning.yml +30 -0
  13. lichess-bot-master/.gitignore +11 -0
  14. lichess-bot-master/CITATION.cff +36 -0
  15. lichess-bot-master/LICENSE +661 -0
  16. lichess-bot-master/README.md +65 -0
  17. lichess-bot-master/config.yml.default +253 -0
  18. lichess-bot-master/docker/Dockerfile +22 -0
  19. lichess-bot-master/docker/Dockerfile.dockerignore +6 -0
  20. lichess-bot-master/docker/copy_files.sh +9 -0
  21. lichess-bot-master/docs/CODE_OF_CONDUCT.md +65 -0
  22. lichess-bot-master/docs/CONTRIBUTING.md +57 -0
  23. lichess-bot-master/docs/SECURITY.md +9 -0
  24. lichess-bot-master/engines/README.md +1 -0
  25. lichess-bot-master/extra_game_handlers.py +21 -0
  26. lichess-bot-master/homemade.py +96 -0
  27. lichess-bot-master/lib/__init__.py +1 -0
  28. lichess-bot-master/lib/blocklist.py +51 -0
  29. lichess-bot-master/lib/config.py +449 -0
  30. lichess-bot-master/lib/conversation.py +106 -0
  31. lichess-bot-master/lib/engine_wrapper.py +1427 -0
  32. lichess-bot-master/lib/lichess.py +474 -0
  33. lichess-bot-master/lib/lichess_bot.py +1277 -0
  34. lichess-bot-master/lib/lichess_types.py +468 -0
  35. lichess-bot-master/lib/matchmaking.py +361 -0
  36. lichess-bot-master/lib/model.py +329 -0
  37. lichess-bot-master/lib/timer.py +98 -0
  38. lichess-bot-master/lib/versioning.yml +4 -0
  39. lichess-bot-master/lichess-bot.py +5 -0
  40. lichess-bot-master/requirements.txt +5 -0
  41. lichess-bot-master/test_bot/__init__.py +1 -0
  42. lichess-bot-master/test_bot/buggy_engine.py +52 -0
  43. lichess-bot-master/test_bot/conftest.py +15 -0
  44. lichess-bot-master/test_bot/homemade.py +25 -0
  45. lichess-bot-master/test_bot/lichess.py +262 -0
  46. lichess-bot-master/test_bot/ruff.toml +46 -0
  47. lichess-bot-master/test_bot/test-requirements.txt +6 -0
  48. lichess-bot-master/test_bot/test_blocklist.py +227 -0
  49. lichess-bot-master/test_bot/test_bot.py +197 -0
  50. lichess-bot-master/test_bot/test_config.py +33 -0
lichess-bot-master/.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Normalize line endings
2
+ * text=auto
lichess-bot-master/.github/ISSUE_TEMPLATE/bug_report.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.<date>` 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.
lichess-bot-master/.github/ISSUE_TEMPLATE/feature_request.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
lichess-bot-master/.github/dependabot.yml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"
lichess-bot-master/.github/pull_request_template.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):
lichess-bot-master/.github/workflows/docker.yml ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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@v6
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@v4
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@v4
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@v4
43
+
44
+ - name: Set up Docker Buildx
45
+ uses: docker/setup-buildx-action@v4
46
+
47
+ - name: build and push alpine image
48
+ uses: docker/build-push-action@v7
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@v7
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: peter-evans/dockerhub-description@v5
80
+ with:
81
+ username: ${{ secrets.DOCKER_USER }}
82
+ password: ${{ secrets.DOCKER_TOKEN }}
83
+ repository: lichessbotdevs/lichess-bot
84
+ short-description: 'A bridge between Lichess API and chess engines'
85
+ readme-filepath: './wiki/How-to-use-the-Docker-image.md'
lichess-bot-master/.github/workflows/mypy.yml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.10", "3.14"]
18
+
19
+ steps:
20
+ - uses: actions/checkout@v6
21
+ - name: Set up Python ${{ matrix.python }}
22
+ uses: actions/setup-python@v6
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 .
lichess-bot-master/.github/workflows/python-build.yml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.10", "3.11", "3.12", "3.13", "3.14"]
19
+
20
+ steps:
21
+ - uses: actions/checkout@v6
22
+ - name: Set up Python ${{ matrix.python }}
23
+ uses: actions/setup-python@v6
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
lichess-bot-master/.github/workflows/python-test.yml ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.10", "3.14"]
19
+
20
+
21
+ steps:
22
+ - uses: actions/checkout@v6
23
+ - name: Set up Python ${{ matrix.python }}
24
+ uses: actions/setup-python@v6
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@v5
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@v5
47
+ with:
48
+ path: |
49
+ TEMP
50
+ key: ${{ steps.cache-temp-restore.outputs.cache-primary-key }}
lichess-bot-master/.github/workflows/sync-wiki.yml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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@v6
18
+ - name: Checkout wiki
19
+ uses: actions/checkout@v6
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
lichess-bot-master/.github/workflows/update_version.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
lichess-bot-master/.github/workflows/versioning.yml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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@v6
13
+ - name: Set up Python 3.11
14
+ uses: actions/setup-python@v6
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
lichess-bot-master/.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
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/*
lichess-bot-master/CITATION.cff ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
lichess-bot-master/LICENSE ADDED
@@ -0,0 +1,661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU Affero General Public License is a free, copyleft license for
11
+ software and other kinds of works, specifically designed to ensure
12
+ cooperation with the community in the case of network server software.
13
+
14
+ The licenses for most software and other practical works are designed
15
+ to take away your freedom to share and change the works. By contrast,
16
+ our General Public Licenses are intended to guarantee your freedom to
17
+ share and change all versions of a program--to make sure it remains free
18
+ software for all its users.
19
+
20
+ When we speak of free software, we are referring to freedom, not
21
+ price. Our General Public Licenses are designed to make sure that you
22
+ have the freedom to distribute copies of free software (and charge for
23
+ them if you wish), that you receive source code or can get it if you
24
+ want it, that you can change the software or use pieces of it in new
25
+ free programs, and that you know you can do these things.
26
+
27
+ Developers that use our General Public Licenses protect your rights
28
+ with two steps: (1) assert copyright on the software, and (2) offer
29
+ you this License which gives you legal permission to copy, distribute
30
+ and/or modify the software.
31
+
32
+ A secondary benefit of defending all users' freedom is that
33
+ improvements made in alternate versions of the program, if they
34
+ receive widespread use, become available for other developers to
35
+ incorporate. Many developers of free software are heartened and
36
+ encouraged by the resulting cooperation. However, in the case of
37
+ software used on network servers, this result may fail to come about.
38
+ The GNU General Public License permits making a modified version and
39
+ letting the public access it on a server without ever releasing its
40
+ source code to the public.
41
+
42
+ The GNU Affero General Public License is designed specifically to
43
+ ensure that, in such cases, the modified source code becomes available
44
+ to the community. It requires the operator of a network server to
45
+ provide the source code of the modified version running there to the
46
+ users of that server. Therefore, public use of a modified version, on
47
+ a publicly accessible server, gives the public access to the source
48
+ code of the modified version.
49
+
50
+ An older license, called the Affero General Public License and
51
+ published by Affero, was designed to accomplish similar goals. This is
52
+ a different license, not a version of the Affero GPL, but Affero has
53
+ released a new version of the Affero GPL which permits relicensing under
54
+ this license.
55
+
56
+ The precise terms and conditions for copying, distribution and
57
+ modification follow.
58
+
59
+ TERMS AND CONDITIONS
60
+
61
+ 0. Definitions.
62
+
63
+ "This License" refers to version 3 of the GNU Affero General Public License.
64
+
65
+ "Copyright" also means copyright-like laws that apply to other kinds of
66
+ works, such as semiconductor masks.
67
+
68
+ "The Program" refers to any copyrightable work licensed under this
69
+ License. Each licensee is addressed as "you". "Licensees" and
70
+ "recipients" may be individuals or organizations.
71
+
72
+ To "modify" a work means to copy from or adapt all or part of the work
73
+ in a fashion requiring copyright permission, other than the making of an
74
+ exact copy. The resulting work is called a "modified version" of the
75
+ earlier work or a work "based on" the earlier work.
76
+
77
+ A "covered work" means either the unmodified Program or a work based
78
+ on the Program.
79
+
80
+ To "propagate" a work means to do anything with it that, without
81
+ permission, would make you directly or secondarily liable for
82
+ infringement under applicable copyright law, except executing it on a
83
+ computer or modifying a private copy. Propagation includes copying,
84
+ distribution (with or without modification), making available to the
85
+ public, and in some countries other activities as well.
86
+
87
+ To "convey" a work means any kind of propagation that enables other
88
+ parties to make or receive copies. Mere interaction with a user through
89
+ a computer network, with no transfer of a copy, is not conveying.
90
+
91
+ An interactive user interface displays "Appropriate Legal Notices"
92
+ to the extent that it includes a convenient and prominently visible
93
+ feature that (1) displays an appropriate copyright notice, and (2)
94
+ tells the user that there is no warranty for the work (except to the
95
+ extent that warranties are provided), that licensees may convey the
96
+ work under this License, and how to view a copy of this License. If
97
+ the interface presents a list of user commands or options, such as a
98
+ menu, a prominent item in the list meets this criterion.
99
+
100
+ 1. Source Code.
101
+
102
+ The "source code" for a work means the preferred form of the work
103
+ for making modifications to it. "Object code" means any non-source
104
+ form of a work.
105
+
106
+ A "Standard Interface" means an interface that either is an official
107
+ standard defined by a recognized standards body, or, in the case of
108
+ interfaces specified for a particular programming language, one that
109
+ is widely used among developers working in that language.
110
+
111
+ The "System Libraries" of an executable work include anything, other
112
+ than the work as a whole, that (a) is included in the normal form of
113
+ packaging a Major Component, but which is not part of that Major
114
+ Component, and (b) serves only to enable use of the work with that
115
+ Major Component, or to implement a Standard Interface for which an
116
+ implementation is available to the public in source code form. A
117
+ "Major Component", in this context, means a major essential component
118
+ (kernel, window system, and so on) of the specific operating system
119
+ (if any) on which the executable work runs, or a compiler used to
120
+ produce the work, or an object code interpreter used to run it.
121
+
122
+ The "Corresponding Source" for a work in object code form means all
123
+ the source code needed to generate, install, and (for an executable
124
+ work) run the object code and to modify the work, including scripts to
125
+ control those activities. However, it does not include the work's
126
+ System Libraries, or general-purpose tools or generally available free
127
+ programs which are used unmodified in performing those activities but
128
+ which are not part of the work. For example, Corresponding Source
129
+ includes interface definition files associated with source files for
130
+ the work, and the source code for shared libraries and dynamically
131
+ linked subprograms that the work is specifically designed to require,
132
+ such as by intimate data communication or control flow between those
133
+ subprograms and other parts of the work.
134
+
135
+ The Corresponding Source need not include anything that users
136
+ can regenerate automatically from other parts of the Corresponding
137
+ Source.
138
+
139
+ The Corresponding Source for a work in source code form is that
140
+ same work.
141
+
142
+ 2. Basic Permissions.
143
+
144
+ All rights granted under this License are granted for the term of
145
+ copyright on the Program, and are irrevocable provided the stated
146
+ conditions are met. This License explicitly affirms your unlimited
147
+ permission to run the unmodified Program. The output from running a
148
+ covered work is covered by this License only if the output, given its
149
+ content, constitutes a covered work. This License acknowledges your
150
+ rights of fair use or other equivalent, as provided by copyright law.
151
+
152
+ You may make, run and propagate covered works that you do not
153
+ convey, without conditions so long as your license otherwise remains
154
+ in force. You may convey covered works to others for the sole purpose
155
+ of having them make modifications exclusively for you, or provide you
156
+ with facilities for running those works, provided that you comply with
157
+ the terms of this License in conveying all material for which you do
158
+ not control copyright. Those thus making or running the covered works
159
+ for you must do so exclusively on your behalf, under your direction
160
+ and control, on terms that prohibit them from making any copies of
161
+ your copyrighted material outside their relationship with you.
162
+
163
+ Conveying under any other circumstances is permitted solely under
164
+ the conditions stated below. Sublicensing is not allowed; section 10
165
+ makes it unnecessary.
166
+
167
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168
+
169
+ No covered work shall be deemed part of an effective technological
170
+ measure under any applicable law fulfilling obligations under article
171
+ 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172
+ similar laws prohibiting or restricting circumvention of such
173
+ measures.
174
+
175
+ When you convey a covered work, you waive any legal power to forbid
176
+ circumvention of technological measures to the extent such circumvention
177
+ is effected by exercising rights under this License with respect to
178
+ the covered work, and you disclaim any intention to limit operation or
179
+ modification of the work as a means of enforcing, against the work's
180
+ users, your or third parties' legal rights to forbid circumvention of
181
+ technological measures.
182
+
183
+ 4. Conveying Verbatim Copies.
184
+
185
+ You may convey verbatim copies of the Program's source code as you
186
+ receive it, in any medium, provided that you conspicuously and
187
+ appropriately publish on each copy an appropriate copyright notice;
188
+ keep intact all notices stating that this License and any
189
+ non-permissive terms added in accord with section 7 apply to the code;
190
+ keep intact all notices of the absence of any warranty; and give all
191
+ recipients a copy of this License along with the Program.
192
+
193
+ You may charge any price or no price for each copy that you convey,
194
+ and you may offer support or warranty protection for a fee.
195
+
196
+ 5. Conveying Modified Source Versions.
197
+
198
+ You may convey a work based on the Program, or the modifications to
199
+ produce it from the Program, in the form of source code under the
200
+ terms of section 4, provided that you also meet all of these conditions:
201
+
202
+ a) The work must carry prominent notices stating that you modified
203
+ it, and giving a relevant date.
204
+
205
+ b) The work must carry prominent notices stating that it is
206
+ released under this License and any conditions added under section
207
+ 7. This requirement modifies the requirement in section 4 to
208
+ "keep intact all notices".
209
+
210
+ c) You must license the entire work, as a whole, under this
211
+ License to anyone who comes into possession of a copy. This
212
+ License will therefore apply, along with any applicable section 7
213
+ additional terms, to the whole of the work, and all its parts,
214
+ regardless of how they are packaged. This License gives no
215
+ permission to license the work in any other way, but it does not
216
+ invalidate such permission if you have separately received it.
217
+
218
+ d) If the work has interactive user interfaces, each must display
219
+ Appropriate Legal Notices; however, if the Program has interactive
220
+ interfaces that do not display Appropriate Legal Notices, your
221
+ work need not make them do so.
222
+
223
+ A compilation of a covered work with other separate and independent
224
+ works, which are not by their nature extensions of the covered work,
225
+ and which are not combined with it such as to form a larger program,
226
+ in or on a volume of a storage or distribution medium, is called an
227
+ "aggregate" if the compilation and its resulting copyright are not
228
+ used to limit the access or legal rights of the compilation's users
229
+ beyond what the individual works permit. Inclusion of a covered work
230
+ in an aggregate does not cause this License to apply to the other
231
+ parts of the aggregate.
232
+
233
+ 6. Conveying Non-Source Forms.
234
+
235
+ You may convey a covered work in object code form under the terms
236
+ of sections 4 and 5, provided that you also convey the
237
+ machine-readable Corresponding Source under the terms of this License,
238
+ in one of these ways:
239
+
240
+ a) Convey the object code in, or embodied in, a physical product
241
+ (including a physical distribution medium), accompanied by the
242
+ Corresponding Source fixed on a durable physical medium
243
+ customarily used for software interchange.
244
+
245
+ b) Convey the object code in, or embodied in, a physical product
246
+ (including a physical distribution medium), accompanied by a
247
+ written offer, valid for at least three years and valid for as
248
+ long as you offer spare parts or customer support for that product
249
+ model, to give anyone who possesses the object code either (1) a
250
+ copy of the Corresponding Source for all the software in the
251
+ product that is covered by this License, on a durable physical
252
+ medium customarily used for software interchange, for a price no
253
+ more than your reasonable cost of physically performing this
254
+ conveying of source, or (2) access to copy the
255
+ Corresponding Source from a network server at no charge.
256
+
257
+ c) Convey individual copies of the object code with a copy of the
258
+ written offer to provide the Corresponding Source. This
259
+ alternative is allowed only occasionally and noncommercially, and
260
+ only if you received the object code with such an offer, in accord
261
+ with subsection 6b.
262
+
263
+ d) Convey the object code by offering access from a designated
264
+ place (gratis or for a charge), and offer equivalent access to the
265
+ Corresponding Source in the same way through the same place at no
266
+ further charge. You need not require recipients to copy the
267
+ Corresponding Source along with the object code. If the place to
268
+ copy the object code is a network server, the Corresponding Source
269
+ may be on a different server (operated by you or a third party)
270
+ that supports equivalent copying facilities, provided you maintain
271
+ clear directions next to the object code saying where to find the
272
+ Corresponding Source. Regardless of what server hosts the
273
+ Corresponding Source, you remain obligated to ensure that it is
274
+ available for as long as needed to satisfy these requirements.
275
+
276
+ e) Convey the object code using peer-to-peer transmission, provided
277
+ you inform other peers where the object code and Corresponding
278
+ Source of the work are being offered to the general public at no
279
+ charge under subsection 6d.
280
+
281
+ A separable portion of the object code, whose source code is excluded
282
+ from the Corresponding Source as a System Library, need not be
283
+ included in conveying the object code work.
284
+
285
+ A "User Product" is either (1) a "consumer product", which means any
286
+ tangible personal property which is normally used for personal, family,
287
+ or household purposes, or (2) anything designed or sold for incorporation
288
+ into a dwelling. In determining whether a product is a consumer product,
289
+ doubtful cases shall be resolved in favor of coverage. For a particular
290
+ product received by a particular user, "normally used" refers to a
291
+ typical or common use of that class of product, regardless of the status
292
+ of the particular user or of the way in which the particular user
293
+ actually uses, or expects or is expected to use, the product. A product
294
+ is a consumer product regardless of whether the product has substantial
295
+ commercial, industrial or non-consumer uses, unless such uses represent
296
+ the only significant mode of use of the product.
297
+
298
+ "Installation Information" for a User Product means any methods,
299
+ procedures, authorization keys, or other information required to install
300
+ and execute modified versions of a covered work in that User Product from
301
+ a modified version of its Corresponding Source. The information must
302
+ suffice to ensure that the continued functioning of the modified object
303
+ code is in no case prevented or interfered with solely because
304
+ modification has been made.
305
+
306
+ If you convey an object code work under this section in, or with, or
307
+ specifically for use in, a User Product, and the conveying occurs as
308
+ part of a transaction in which the right of possession and use of the
309
+ User Product is transferred to the recipient in perpetuity or for a
310
+ fixed term (regardless of how the transaction is characterized), the
311
+ Corresponding Source conveyed under this section must be accompanied
312
+ by the Installation Information. But this requirement does not apply
313
+ if neither you nor any third party retains the ability to install
314
+ modified object code on the User Product (for example, the work has
315
+ been installed in ROM).
316
+
317
+ The requirement to provide Installation Information does not include a
318
+ requirement to continue to provide support service, warranty, or updates
319
+ for a work that has been modified or installed by the recipient, or for
320
+ the User Product in which it has been modified or installed. Access to a
321
+ network may be denied when the modification itself materially and
322
+ adversely affects the operation of the network or violates the rules and
323
+ protocols for communication across the network.
324
+
325
+ Corresponding Source conveyed, and Installation Information provided,
326
+ in accord with this section must be in a format that is publicly
327
+ documented (and with an implementation available to the public in
328
+ source code form), and must require no special password or key for
329
+ unpacking, reading or copying.
330
+
331
+ 7. Additional Terms.
332
+
333
+ "Additional permissions" are terms that supplement the terms of this
334
+ License by making exceptions from one or more of its conditions.
335
+ Additional permissions that are applicable to the entire Program shall
336
+ be treated as though they were included in this License, to the extent
337
+ that they are valid under applicable law. If additional permissions
338
+ apply only to part of the Program, that part may be used separately
339
+ under those permissions, but the entire Program remains governed by
340
+ this License without regard to the additional permissions.
341
+
342
+ When you convey a copy of a covered work, you may at your option
343
+ remove any additional permissions from that copy, or from any part of
344
+ it. (Additional permissions may be written to require their own
345
+ removal in certain cases when you modify the work.) You may place
346
+ additional permissions on material, added by you to a covered work,
347
+ for which you have or can give appropriate copyright permission.
348
+
349
+ Notwithstanding any other provision of this License, for material you
350
+ add to a covered work, you may (if authorized by the copyright holders of
351
+ that material) supplement the terms of this License with terms:
352
+
353
+ a) Disclaiming warranty or limiting liability differently from the
354
+ terms of sections 15 and 16 of this License; or
355
+
356
+ b) Requiring preservation of specified reasonable legal notices or
357
+ author attributions in that material or in the Appropriate Legal
358
+ Notices displayed by works containing it; or
359
+
360
+ c) Prohibiting misrepresentation of the origin of that material, or
361
+ requiring that modified versions of such material be marked in
362
+ reasonable ways as different from the original version; or
363
+
364
+ d) Limiting the use for publicity purposes of names of licensors or
365
+ authors of the material; or
366
+
367
+ e) Declining to grant rights under trademark law for use of some
368
+ trade names, trademarks, or service marks; or
369
+
370
+ f) Requiring indemnification of licensors and authors of that
371
+ material by anyone who conveys the material (or modified versions of
372
+ it) with contractual assumptions of liability to the recipient, for
373
+ any liability that these contractual assumptions directly impose on
374
+ those licensors and authors.
375
+
376
+ All other non-permissive additional terms are considered "further
377
+ restrictions" within the meaning of section 10. If the Program as you
378
+ received it, or any part of it, contains a notice stating that it is
379
+ governed by this License along with a term that is a further
380
+ restriction, you may remove that term. If a license document contains
381
+ a further restriction but permits relicensing or conveying under this
382
+ License, you may add to a covered work material governed by the terms
383
+ of that license document, provided that the further restriction does
384
+ not survive such relicensing or conveying.
385
+
386
+ If you add terms to a covered work in accord with this section, you
387
+ must place, in the relevant source files, a statement of the
388
+ additional terms that apply to those files, or a notice indicating
389
+ where to find the applicable terms.
390
+
391
+ Additional terms, permissive or non-permissive, may be stated in the
392
+ form of a separately written license, or stated as exceptions;
393
+ the above requirements apply either way.
394
+
395
+ 8. Termination.
396
+
397
+ You may not propagate or modify a covered work except as expressly
398
+ provided under this License. Any attempt otherwise to propagate or
399
+ modify it is void, and will automatically terminate your rights under
400
+ this License (including any patent licenses granted under the third
401
+ paragraph of section 11).
402
+
403
+ However, if you cease all violation of this License, then your
404
+ license from a particular copyright holder is reinstated (a)
405
+ provisionally, unless and until the copyright holder explicitly and
406
+ finally terminates your license, and (b) permanently, if the copyright
407
+ holder fails to notify you of the violation by some reasonable means
408
+ prior to 60 days after the cessation.
409
+
410
+ Moreover, your license from a particular copyright holder is
411
+ reinstated permanently if the copyright holder notifies you of the
412
+ violation by some reasonable means, this is the first time you have
413
+ received notice of violation of this License (for any work) from that
414
+ copyright holder, and you cure the violation prior to 30 days after
415
+ your receipt of the notice.
416
+
417
+ Termination of your rights under this section does not terminate the
418
+ licenses of parties who have received copies or rights from you under
419
+ this License. If your rights have been terminated and not permanently
420
+ reinstated, you do not qualify to receive new licenses for the same
421
+ material under section 10.
422
+
423
+ 9. Acceptance Not Required for Having Copies.
424
+
425
+ You are not required to accept this License in order to receive or
426
+ run a copy of the Program. Ancillary propagation of a covered work
427
+ occurring solely as a consequence of using peer-to-peer transmission
428
+ to receive a copy likewise does not require acceptance. However,
429
+ nothing other than this License grants you permission to propagate or
430
+ modify any covered work. These actions infringe copyright if you do
431
+ not accept this License. Therefore, by modifying or propagating a
432
+ covered work, you indicate your acceptance of this License to do so.
433
+
434
+ 10. Automatic Licensing of Downstream Recipients.
435
+
436
+ Each time you convey a covered work, the recipient automatically
437
+ receives a license from the original licensors, to run, modify and
438
+ propagate that work, subject to this License. You are not responsible
439
+ for enforcing compliance by third parties with this License.
440
+
441
+ An "entity transaction" is a transaction transferring control of an
442
+ organization, or substantially all assets of one, or subdividing an
443
+ organization, or merging organizations. If propagation of a covered
444
+ work results from an entity transaction, each party to that
445
+ transaction who receives a copy of the work also receives whatever
446
+ licenses to the work the party's predecessor in interest had or could
447
+ give under the previous paragraph, plus a right to possession of the
448
+ Corresponding Source of the work from the predecessor in interest, if
449
+ the predecessor has it or can get it with reasonable efforts.
450
+
451
+ You may not impose any further restrictions on the exercise of the
452
+ rights granted or affirmed under this License. For example, you may
453
+ not impose a license fee, royalty, or other charge for exercise of
454
+ rights granted under this License, and you may not initiate litigation
455
+ (including a cross-claim or counterclaim in a lawsuit) alleging that
456
+ any patent claim is infringed by making, using, selling, offering for
457
+ sale, or importing the Program or any portion of it.
458
+
459
+ 11. Patents.
460
+
461
+ A "contributor" is a copyright holder who authorizes use under this
462
+ License of the Program or a work on which the Program is based. The
463
+ work thus licensed is called the contributor's "contributor version".
464
+
465
+ A contributor's "essential patent claims" are all patent claims
466
+ owned or controlled by the contributor, whether already acquired or
467
+ hereafter acquired, that would be infringed by some manner, permitted
468
+ by this License, of making, using, or selling its contributor version,
469
+ but do not include claims that would be infringed only as a
470
+ consequence of further modification of the contributor version. For
471
+ purposes of this definition, "control" includes the right to grant
472
+ patent sublicenses in a manner consistent with the requirements of
473
+ this License.
474
+
475
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
476
+ patent license under the contributor's essential patent claims, to
477
+ make, use, sell, offer for sale, import and otherwise run, modify and
478
+ propagate the contents of its contributor version.
479
+
480
+ In the following three paragraphs, a "patent license" is any express
481
+ agreement or commitment, however denominated, not to enforce a patent
482
+ (such as an express permission to practice a patent or covenant not to
483
+ sue for patent infringement). To "grant" such a patent license to a
484
+ party means to make such an agreement or commitment not to enforce a
485
+ patent against the party.
486
+
487
+ If you convey a covered work, knowingly relying on a patent license,
488
+ and the Corresponding Source of the work is not available for anyone
489
+ to copy, free of charge and under the terms of this License, through a
490
+ publicly available network server or other readily accessible means,
491
+ then you must either (1) cause the Corresponding Source to be so
492
+ available, or (2) arrange to deprive yourself of the benefit of the
493
+ patent license for this particular work, or (3) arrange, in a manner
494
+ consistent with the requirements of this License, to extend the patent
495
+ license to downstream recipients. "Knowingly relying" means you have
496
+ actual knowledge that, but for the patent license, your conveying the
497
+ covered work in a country, or your recipient's use of the covered work
498
+ in a country, would infringe one or more identifiable patents in that
499
+ country that you have reason to believe are valid.
500
+
501
+ If, pursuant to or in connection with a single transaction or
502
+ arrangement, you convey, or propagate by procuring conveyance of, a
503
+ covered work, and grant a patent license to some of the parties
504
+ receiving the covered work authorizing them to use, propagate, modify
505
+ or convey a specific copy of the covered work, then the patent license
506
+ you grant is automatically extended to all recipients of the covered
507
+ work and works based on it.
508
+
509
+ A patent license is "discriminatory" if it does not include within
510
+ the scope of its coverage, prohibits the exercise of, or is
511
+ conditioned on the non-exercise of one or more of the rights that are
512
+ specifically granted under this License. You may not convey a covered
513
+ work if you are a party to an arrangement with a third party that is
514
+ in the business of distributing software, under which you make payment
515
+ to the third party based on the extent of your activity of conveying
516
+ the work, and under which the third party grants, to any of the
517
+ parties who would receive the covered work from you, a discriminatory
518
+ patent license (a) in connection with copies of the covered work
519
+ conveyed by you (or copies made from those copies), or (b) primarily
520
+ for and in connection with specific products or compilations that
521
+ contain the covered work, unless you entered into that arrangement,
522
+ or that patent license was granted, prior to 28 March 2007.
523
+
524
+ Nothing in this License shall be construed as excluding or limiting
525
+ any implied license or other defenses to infringement that may
526
+ otherwise be available to you under applicable patent law.
527
+
528
+ 12. No Surrender of Others' Freedom.
529
+
530
+ If conditions are imposed on you (whether by court order, agreement or
531
+ otherwise) that contradict the conditions of this License, they do not
532
+ excuse you from the conditions of this License. If you cannot convey a
533
+ covered work so as to satisfy simultaneously your obligations under this
534
+ License and any other pertinent obligations, then as a consequence you may
535
+ not convey it at all. For example, if you agree to terms that obligate you
536
+ to collect a royalty for further conveying from those to whom you convey
537
+ the Program, the only way you could satisfy both those terms and this
538
+ License would be to refrain entirely from conveying the Program.
539
+
540
+ 13. Remote Network Interaction; Use with the GNU General Public License.
541
+
542
+ Notwithstanding any other provision of this License, if you modify the
543
+ Program, your modified version must prominently offer all users
544
+ interacting with it remotely through a computer network (if your version
545
+ supports such interaction) an opportunity to receive the Corresponding
546
+ Source of your version by providing access to the Corresponding Source
547
+ from a network server at no charge, through some standard or customary
548
+ means of facilitating copying of software. This Corresponding Source
549
+ shall include the Corresponding Source for any work covered by version 3
550
+ of the GNU General Public License that is incorporated pursuant to the
551
+ following paragraph.
552
+
553
+ Notwithstanding any other provision of this License, you have
554
+ permission to link or combine any covered work with a work licensed
555
+ under version 3 of the GNU General Public License into a single
556
+ combined work, and to convey the resulting work. The terms of this
557
+ License will continue to apply to the part which is the covered work,
558
+ but the work with which it is combined will remain governed by version
559
+ 3 of the GNU General Public License.
560
+
561
+ 14. Revised Versions of this License.
562
+
563
+ The Free Software Foundation may publish revised and/or new versions of
564
+ the GNU Affero General Public License from time to time. Such new versions
565
+ will be similar in spirit to the present version, but may differ in detail to
566
+ address new problems or concerns.
567
+
568
+ Each version is given a distinguishing version number. If the
569
+ Program specifies that a certain numbered version of the GNU Affero General
570
+ Public License "or any later version" applies to it, you have the
571
+ option of following the terms and conditions either of that numbered
572
+ version or of any later version published by the Free Software
573
+ Foundation. If the Program does not specify a version number of the
574
+ GNU Affero General Public License, you may choose any version ever published
575
+ by the Free Software Foundation.
576
+
577
+ If the Program specifies that a proxy can decide which future
578
+ versions of the GNU Affero General Public License can be used, that proxy's
579
+ public statement of acceptance of a version permanently authorizes you
580
+ to choose that version for the Program.
581
+
582
+ Later license versions may give you additional or different
583
+ permissions. However, no additional obligations are imposed on any
584
+ author or copyright holder as a result of your choosing to follow a
585
+ later version.
586
+
587
+ 15. Disclaimer of Warranty.
588
+
589
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590
+ APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591
+ HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592
+ OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593
+ THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594
+ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595
+ IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596
+ ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597
+
598
+ 16. Limitation of Liability.
599
+
600
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601
+ WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602
+ THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603
+ GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604
+ USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605
+ DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606
+ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607
+ EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608
+ SUCH DAMAGES.
609
+
610
+ 17. Interpretation of Sections 15 and 16.
611
+
612
+ If the disclaimer of warranty and limitation of liability provided
613
+ above cannot be given local legal effect according to their terms,
614
+ reviewing courts shall apply local law that most closely approximates
615
+ an absolute waiver of all civil liability in connection with the
616
+ Program, unless a warranty or assumption of liability accompanies a
617
+ copy of the Program in return for a fee.
618
+
619
+ END OF TERMS AND CONDITIONS
620
+
621
+ How to Apply These Terms to Your New Programs
622
+
623
+ If you develop a new program, and you want it to be of the greatest
624
+ possible use to the public, the best way to achieve this is to make it
625
+ free software which everyone can redistribute and change under these terms.
626
+
627
+ To do so, attach the following notices to the program. It is safest
628
+ to attach them to the start of each source file to most effectively
629
+ state the exclusion of warranty; and each file should have at least
630
+ the "copyright" line and a pointer to where the full notice is found.
631
+
632
+ <one line to give the program's name and a brief idea of what it does.>
633
+ Copyright (C) <year> <name of author>
634
+
635
+ This program is free software: you can redistribute it and/or modify
636
+ it under the terms of the GNU Affero General Public License as published by
637
+ the Free Software Foundation, either version 3 of the License, or
638
+ (at your option) any later version.
639
+
640
+ This program is distributed in the hope that it will be useful,
641
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
642
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643
+ GNU Affero General Public License for more details.
644
+
645
+ You should have received a copy of the GNU Affero General Public License
646
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
647
+
648
+ Also add information on how to contact you by electronic and paper mail.
649
+
650
+ If your software can interact with users remotely through a computer
651
+ network, you should also make sure that it provides a way for users to
652
+ get its source. For example, if your program is a web application, its
653
+ interface could display a "Source" link that leads users to an archive
654
+ of the code. There are many ways you could offer source, and different
655
+ solutions will be better for different programs; see section 13 for the
656
+ specific requirements.
657
+
658
+ You should also get your employer (if you work as a programmer) or school,
659
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
660
+ For more information on this, and how to apply and follow the GNU AGPL, see
661
+ <http://www.gnu.org/licenses/>.
lichess-bot-master/README.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ ![lichess-bot](https://github.com/lichess-bot-devs/lichess-bot-images/blob/main/lichess-bot-icon-400.png)
4
+
5
+ <h1>lichess-bot</h1>
6
+
7
+ A bridge between [lichess.org](https://lichess.org) and bots.
8
+ <br>
9
+ <strong>[Explore lichess-bot docs »](https://github.com/lichess-bot-devs/lichess-bot/wiki)</strong>
10
+ <br>
11
+ <br>
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
+ </div>
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.10 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
+ <br />
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 [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).
lichess-bot-master/config.yml.default ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ token: "lip_kKCkBvIIxTKygbXxNv8Z" # Lichess OAuth2 Token.
2
+ url: "https://lichess.org/" # Lichess base URL.
3
+
4
+ engine: # Engine settings.
5
+ dir: "./engines/stockfish" # Directory containing the engine. This can be an absolute path or one relative to lichess-bot/.
6
+ name: "stockfish-ubuntu-x86-64-avx2fd" # Binary name of the engine to use.
7
+ debug: False # When enabled, log all startup communication with the engine.
8
+ # interpreter: "java"
9
+ # interpreter_options:
10
+ # - "-jar"
11
+ working_dir: "" # Directory where the chess engine will read and write files. If blank or missing, the current directory is used.
12
+ # 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.
13
+ protocol: "uci" # "uci", "xboard" or "homemade"
14
+ ponder: true # Think on opponent's time.
15
+
16
+ polyglot:
17
+ enabled: false # Activate polyglot book.
18
+ book:
19
+ standard: # List of book file paths for variant standard.
20
+ - engines/book1.bin
21
+ - engines/book2.bin
22
+ # atomic: # List of book file paths for variant atomic.
23
+ # - engines/atomicbook1.bin
24
+ # - engines/atomicbook2.bin
25
+ # etc.
26
+ # Use the same pattern for 'chess960', 'giveaway' (antichess), 'crazyhouse', 'horde', 'kingofthehill', 'racingkings' and '3check' as well.
27
+ min_weight: 1 # Does not select moves with weight below min_weight (min 0, max: 100 if normalization isn't "none" else 65535).
28
+ 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).
29
+ max_depth: 20 # How many moves from the start to take from the book.
30
+ normalization: "none" # Normalization method for the book weights. One of "none", "sum", or "max".
31
+
32
+ draw_or_resign:
33
+ resign_enabled: false # Whether or not the bot should resign.
34
+ resign_score: -1000 # If the score is less than or equal to this value, the bot resigns (in cp).
35
+ resign_for_egtb_minus_two: true # If true the bot will resign in positions where the online_egtb returns a wdl of -2.
36
+ resign_moves: 3 # How many moves in a row the score has to be below the resign value.
37
+ offer_draw_enabled: true # Whether or not the bot should offer/accept draw.
38
+ 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).
39
+ 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.
40
+ offer_draw_moves: 10 # How many moves in a row the absolute value of the score has to be below the draw value.
41
+ offer_draw_pieces: 10 # Only if the pieces on board are less than or equal to this value, the bot offers/accepts draw.
42
+
43
+ online_moves:
44
+ 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.
45
+ max_retries: 2 # The maximum amount of retries when getting an online move.
46
+ # max_depth: 10 # How many moves from the start to take from online books. Default is no limit.
47
+ chessdb_book:
48
+ enabled: false # Whether or not to use chessdb book.
49
+ min_time: 20 # Minimum time (in seconds) to use chessdb book.
50
+ max_time: 10800 # Maximum starting game time (in seconds) to use chessdb book.
51
+ move_quality: "good" # One of "all", "good", "best".
52
+ min_depth: 20 # Only for move_quality: "best".
53
+ lichess_cloud_analysis:
54
+ enabled: false # Whether or not to use lichess cloud analysis.
55
+ min_time: 20 # Minimum time (in seconds) the bot must have to use cloud analysis.
56
+ max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use cloud analysis.
57
+ move_quality: "best" # One of "good", "best".
58
+ max_score_difference: 50 # Only for move_quality: "good". The maximum score difference (in cp) between the best move and the other moves.
59
+ min_depth: 20
60
+ min_knodes: 0
61
+ lichess_opening_explorer:
62
+ enabled: false
63
+ min_time: 20
64
+ max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use the lichess opening explorer.
65
+ source: "masters" # One of "lichess", "masters", "player"
66
+ player_name: "" # The lichess username. Leave empty for the bot's username to be used. Used only when source is "player".
67
+ sort: "winrate" # One of "winrate", "games_played"
68
+ min_games: 10 # Minimum number of times a move must have been played to be chosen.
69
+ online_egtb:
70
+ enabled: false # Whether or not to enable online endgame tablebases.
71
+ min_time: 20 # Minimum time (in seconds) the bot must have to use online EGTBs.
72
+ max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use online EGTBs.
73
+ max_pieces: 8 # Maximum number of pieces on the board to use endgame tablebases.
74
+ source: "lichess" # One of "lichess", "chessdb".
75
+ 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).
76
+
77
+ lichess_bot_tbs: # The tablebases list here will be read by lichess-bot, not the engine.
78
+ syzygy:
79
+ enabled: false # Whether or not to use local syzygy endgame tablebases.
80
+ paths: # Paths to Syzygy endgame tablebases.
81
+ - "engines/syzygy"
82
+ max_pieces: 7 # Maximum number of pieces in the endgame tablebase.
83
+ 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).
84
+ gaviota:
85
+ enabled: false # Whether or not to use local gaviota endgame tablebases.
86
+ paths:
87
+ - "engines/gaviota"
88
+ max_pieces: 5
89
+ min_dtm_to_consider_as_wdl_1: 120 # The minimum DTM to consider as syzygy WDL=1/-1. Set to 100 to disable.
90
+ 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).
91
+
92
+ # engine_options: # Any custom command line params to pass to the engine.
93
+ # cpuct: 3.1
94
+
95
+ homemade_options:
96
+ # Hash: 256
97
+
98
+ uci_options: # Arbitrary UCI options passed to the engine.
99
+ Move Overhead: 100 # Increase if your bot flags games too often.
100
+ Threads: 4 # Max CPU threads the engine can use.
101
+ Hash: 512 # Max memory (in megabytes) the engine can allocate.
102
+ SyzygyPath: "./syzygy/" # Paths to Syzygy endgame tablebases that the engine reads.
103
+ UCI_ShowWDL: true # Show the chance of the engine winning.
104
+ # go_commands: # Additional options to pass to the UCI go command.
105
+ # nodes: 1 # Search so many nodes only.
106
+ # depth: 5 # Search depth ply only.
107
+ # movetime: 1000 # Integer. Search exactly movetime milliseconds.
108
+
109
+ # xboard_options: # Arbitrary XBoard options passed to the engine.
110
+ # cores: "4"
111
+ # memory: "4096"
112
+ # egtpath: # Directory containing egtb (endgame tablabases), relative to this project. For 'xboard' engines.
113
+ # gaviota: "Gaviota path"
114
+ # nalimov: "Nalimov Path"
115
+ # scorpio: "Scorpio Path"
116
+ # syzygy: "Syzygy Path"
117
+ # go_commands: # Additional options to pass to the XBoard go command.
118
+ # depth: 5 # Search depth ply only.
119
+ # Do note that the go commands 'movetime' and 'nodes' are invalid and may cause bad time management for XBoard engines.
120
+
121
+ silence_stderr: false # Some engines (yes you, Leela) are very noisy.
122
+
123
+ abort_time: 30 # Time to abort a game in seconds when there is no activity.
124
+ fake_think_time: false # Artificially slow down the bot to pretend like it's thinking.
125
+ rate_limiting_delay: 0 # Time (in ms) to delay after sending a move to prevent "Too Many Requests" errors.
126
+ move_overhead: 2000 # Increase if your bot flags games too often.
127
+ max_takebacks_accepted: 0 # The number of times to allow an opponent to take back a move in a game.
128
+ 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.
129
+
130
+ correspondence:
131
+ move_time: 60 # Time in seconds to search in correspondence games.
132
+ checkin_period: 300 # How often to check for opponent moves in correspondence games after disconnecting.
133
+ disconnect_time: 150 # Time before disconnecting from a correspondence game.
134
+ ponder: false # Ponder in correspondence games the bot is connected to.
135
+
136
+ challenge: # Incoming challenges.
137
+ concurrency: 1 # Number of games to play simultaneously.
138
+ sort_by: "best" # Possible values: "best" and "first".
139
+ preference: "none" # Possible values: "none", "human", "bot".
140
+ accept_bot: true # Accepts challenges coming from other bots.
141
+ only_bot: false # Accept challenges by bots only.
142
+ max_increment: 20 # Maximum amount of increment to accept a challenge in seconds. The max is 180. Set to 0 for no increment.
143
+ min_increment: 0 # Minimum amount of increment to accept a challenge in seconds.
144
+ max_base: 1800 # Maximum amount of base time to accept a challenge in seconds. The max is 10800 (3 hours).
145
+ min_base: 0 # Minimum amount of base time to accept a challenge in seconds.
146
+ max_days: 14 # Maximum number of days per move to accept a challenge for a correspondence game.
147
+ # Unlimited games can be accepted by removing this field or specifying .inf
148
+ min_days: 1 # Minimum number of days per move to accept a challenge for a correspondence game.
149
+ variants: # Chess variants to accept (https://lichess.org/variant).
150
+ - standard
151
+ # - fromPosition
152
+ # - antichess
153
+ # - atomic
154
+ # - chess960
155
+ # - crazyhouse
156
+ # - horde
157
+ # - kingOfTheHill
158
+ # - racingKings
159
+ # - threeCheck
160
+ time_controls: # Time controls to accept (bots are not allowed to play ultraBullet).
161
+ - bullet
162
+ - blitz
163
+ - rapid
164
+ - classical
165
+ # - correspondence
166
+ modes: # Game modes to accept.
167
+ - casual # Unrated games.
168
+ - rated # Rated games - must comment if the engine doesn't try to win.
169
+ # block_list: # List of users from which the challenges are always declined.
170
+ # - user1
171
+ # - user2
172
+ # 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
173
+ # - example.com/blocklist
174
+ # allow_list: # List of users from which challenges are exclusively accepted, all others being declined. If empty, challenges from all users may be accepted.
175
+ # - user3
176
+ # - user4
177
+ # recent_bot_challenge_age: 60 # Maximum age of a bot challenge to be considered recent in seconds
178
+ # max_recent_bot_challenges: 2 # Maximum number of recent challenges that can be accepted from the same bot
179
+ # min_rating: 0 # Minimum rating of the challenger to accept a challenge.
180
+ # max_rating: 4000 # Maximum rating of the challenger to accept a challenge. The maximum rating in lichess is 4000.
181
+ # rating_difference: 300 # Maximum rating difference between the bot and the challenger. Overrides min_rating and max_rating if set.
182
+ bullet_requires_increment: false # Require that bullet game challenges from bots have a non-zero increment
183
+ max_simultaneous_games_per_user: 5 # Maximum number of simultaneous games with the same user
184
+
185
+ greeting:
186
+ # Optional substitution keywords (include curly braces):
187
+ # {opponent} to insert opponent's name
188
+ # {me} to insert bot's name
189
+ # Any other words in curly braces will be removed.
190
+ 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
191
+ goodbye: "Good game!" # Message to send to opponent chat at the end of a game
192
+ 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
193
+ goodbye_spectators: "Thanks for watching!" # Message to send to spectator chat at the end of a game
194
+
195
+ # pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept
196
+ # pgn_file_grouping: "game" # How to group games into files. Options are "game", "opponent", and "all"
197
+ # "game" (default) - every game is written to a different file named "{White name} vs. {Black name} - {lichess game ID}.pgn"
198
+ # "opponent" - every game with a given opponent is written to a file named "{Bot name} games vs. {Opponent name}.pgn"
199
+ # "all" - every game is written to a single file named "{Bot name} games.pgn"
200
+
201
+ matchmaking:
202
+ allow_matchmaking: false # Set it to 'true' to challenge other bots.
203
+ allow_during_games: false # Set it to 'true' to create challenges during long games.
204
+ challenge_variant: "random" # If set to 'random', the bot will choose one variant from the variants enabled in 'challenge.variants'.
205
+ challenge_timeout: 30 # Create a challenge after being idle for 'challenge_timeout' minutes. The minimum is 1 minute.
206
+ challenge_initial_time: # Initial time in seconds of the challenge (to be chosen at random).
207
+ - 60
208
+ - 180
209
+ challenge_increment: # Increment in seconds of the challenge (to be chosen at random).
210
+ - 1
211
+ - 2
212
+ # challenge_days: # Days for correspondence challenge (to be chosen at random).
213
+ # - 1
214
+ # - 2
215
+ # opponent_min_rating: 600 # Opponents rating should be above this value (600 is the minimum rating in lichess).
216
+ # opponent_max_rating: 4000 # Opponents rating should be below this value (4000 is the maximum rating in lichess).
217
+ opponent_rating_difference: 300 # The maximum difference in rating between the bot's rating and opponent's rating.
218
+ rating_preference: "none" # One of "none", "high", "low".
219
+ challenge_mode: "random" # Set it to the mode in which challenges are sent. Possible options are 'casual', 'rated' and 'random'.
220
+ 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'.
221
+ # block_list: # The list of bots that will not be challenged
222
+ # - user1
223
+ # - user2
224
+ # 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
225
+ # - example.com/blocklist
226
+ include_challenge_block_list: false # Do not challenge bots in the challenge: block_list in addition to the matchmaking block list.
227
+
228
+ # 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.
229
+ # 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).
230
+ # 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.
231
+ # challenge_initial_time:
232
+ # - 1
233
+ # - 2
234
+ # challenge_increment:
235
+ # - 0
236
+ # - 1
237
+ #
238
+ # easy_chess960:
239
+ # challenge_variant: "chess960"
240
+ # opponent_min_rating: 400
241
+ # opponent_max_rating: 1200
242
+ # opponent_rating_difference:
243
+ # challenge_mode: casual
244
+ #
245
+ # no_pressure_correspondence:
246
+ # challenge_initial_time:
247
+ # challenge_increment:
248
+ # challenge_days:
249
+ # - 2
250
+ # - 3
251
+ # challenge_mode: casual
252
+ #
253
+ # The following configurations cannot be overridden: allow_matchmaking, challenge_timeout, challenge_filter and block_list.
lichess-bot-master/docker/Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ARG VARIANT
2
+ FROM python:3$VARIANT
3
+ LABEL org.opencontainers.image.authors="Lichess Bot Devs <lichess.bot.devs@gmail.com>"
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
lichess-bot-master/docker/Dockerfile.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ docker/*
2
+ !docker/copy_files.sh
3
+ docs/
4
+ wiki/
5
+ .git*
6
+ **/__pycache__
lichess-bot-master/docker/copy_files.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
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
lichess-bot-master/docs/CODE_OF_CONDUCT.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
lichess-bot-master/docs/CONTRIBUTING.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
lichess-bot-master/docs/SECURITY.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
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.
lichess-bot-master/engines/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ Put your engines and opening books here.
lichess-bot-master/extra_game_handlers.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
lichess-bot-master/homemade.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
lichess-bot-master/lib/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """The lib folder contains the library code necessary for running lichess-bot."""
lichess-bot-master/lib/blocklist.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Code related to blocklists."""
2
+ from dataclasses import dataclass
3
+ import requests
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ @dataclass
10
+ class BlocklistData:
11
+ """Dataclass for online blocklist requests, including usernames and etag."""
12
+
13
+ users: list[str]
14
+ etag: str | None
15
+
16
+
17
+ def _parse_block_list_from_url(url: str, old_data: BlocklistData) -> BlocklistData:
18
+ headers = {"If-None-Match": old_data.etag} if old_data.etag else {}
19
+ response = requests.get(url, headers=headers, timeout=15)
20
+
21
+ response.raise_for_status()
22
+
23
+ if response.status_code == 304:
24
+ return old_data
25
+
26
+ block_list = [username for line in response.text.strip().splitlines() if (username := line.strip())]
27
+
28
+ return BlocklistData(block_list, response.headers.get("ETag"))
29
+
30
+
31
+ class OnlineBlocklist:
32
+ """Manage online blocklists."""
33
+
34
+ def __init__(self, urls: list[str]) -> None:
35
+ """Initialize the OnlineBlockList class."""
36
+ self.blocklist: dict[str, BlocklistData] = {url : BlocklistData([], None) for url in urls}
37
+ self.refresh()
38
+
39
+ def refresh(self) -> None:
40
+ """Pull updated blocklists from the list of blocklist urls."""
41
+ logger.info(f"Refreshing {len(self.blocklist)} online blocklists")
42
+
43
+ for url, data in self.blocklist.items():
44
+ try:
45
+ self.blocklist[url] = _parse_block_list_from_url(url, data)
46
+ except Exception:
47
+ logger.warning(f"Failed to refresh online blocklist {url}")
48
+
49
+ def __contains__(self, item: str) -> bool:
50
+ """Check if an username is in the blocklist."""
51
+ return any(item in blocklist.users for blocklist in self.blocklist.values())
lichess-bot-master/lib/config.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from typing import Any
8
+ from collections.abc import ItemsView, Callable
9
+ from lib.lichess_types import CONFIG_DICT_TYPE, FilterType
10
+ from lib.timer import minutes, days
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: 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: type, 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: dict[type, str] = {
92
+ str: f"{data_location} must be a string wrapped in quotes.",
93
+ dict: f"{data_location} must be a dictionary with indented keys followed by colons."}
94
+ config_assert(data_name in config_part, f"Your config.yml does not have required {sub}section `{data_name}`.")
95
+ config_assert(isinstance(config_part[data_name], data_type), type_error_message[data_type])
96
+
97
+
98
+ def set_config_default(config: CONFIG_DICT_TYPE, *sections: str, key: str, default: Any,
99
+ force_empty_values: bool = False) -> CONFIG_DICT_TYPE:
100
+ """
101
+ Fill a specific config key with the default value if it is missing.
102
+
103
+ :param config: The bot's config.
104
+ :param sections: The sections that the key is in.
105
+ :param key: The key to set.
106
+ :param default: The default value.
107
+ :param force_empty_values: Whether an empty value should be replaced with the default value.
108
+ :return: The new config with the default value inserted if needed.
109
+ """
110
+ subconfig = config
111
+ for section in sections:
112
+ subconfig = subconfig.setdefault(section, {})
113
+ if not isinstance(subconfig, dict):
114
+ raise Exception(f"The {section} section in {sections} should hold a set of key-value pairs, not a value.")
115
+ if force_empty_values:
116
+ if subconfig.get(key) in [None, ""]:
117
+ subconfig[key] = default
118
+ else:
119
+ subconfig.setdefault(key, default)
120
+ return subconfig
121
+
122
+
123
+ def change_value_to_list(config: CONFIG_DICT_TYPE, *sections: str, key: str) -> None:
124
+ """
125
+ Change a single value to a list. e.g. 60 becomes [60]. Used to maintain backwards compatibility.
126
+
127
+ :param config: The bot's config.
128
+ :param sections: The sections that the key is in.
129
+ :param key: The key to set.
130
+ """
131
+ subconfig = set_config_default(config, *sections, key=key, default=[])
132
+
133
+ if subconfig[key] is None:
134
+ subconfig[key] = []
135
+
136
+ if not isinstance(subconfig[key], list):
137
+ subconfig[key] = [subconfig[key]]
138
+
139
+
140
+ def insert_default_values(CONFIG: CONFIG_DICT_TYPE) -> None:
141
+ """
142
+ Insert the default values of most keys to the config if they are missing.
143
+
144
+ :param CONFIG: The bot's config.
145
+ """
146
+ set_config_default(CONFIG, key="abort_time", default=20)
147
+ set_config_default(CONFIG, key="move_overhead", default=1000)
148
+ set_config_default(CONFIG, key="quit_after_all_games_finish", default=False)
149
+ set_config_default(CONFIG, key="rate_limiting_delay", default=0)
150
+ set_config_default(CONFIG, key="pgn_directory", default=None)
151
+ set_config_default(CONFIG, key="pgn_file_grouping", default="game", force_empty_values=True)
152
+ set_config_default(CONFIG, key="max_takebacks_accepted", default=0, force_empty_values=True)
153
+ set_config_default(CONFIG, "engine", key="interpreter", default=None)
154
+ set_config_default(CONFIG, "engine", key="interpreter_options", default=[], force_empty_values=True)
155
+ change_value_to_list(CONFIG, "engine", key="interpreter_options")
156
+ set_config_default(CONFIG, "engine", key="working_dir", default=os.getcwd(), force_empty_values=True)
157
+ set_config_default(CONFIG, "engine", key="silence_stderr", default=False)
158
+ set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_enabled", default=False)
159
+ set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_for_egtb_zero", default=True)
160
+ set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_enabled", default=False)
161
+ set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_for_egtb_minus_two", default=True)
162
+ set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_moves", default=3)
163
+ set_config_default(CONFIG, "engine", "draw_or_resign", key="resign_score", default=-1000)
164
+ set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_moves", default=5)
165
+ set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_score", default=0)
166
+ set_config_default(CONFIG, "engine", "draw_or_resign", key="offer_draw_pieces", default=10)
167
+ set_config_default(CONFIG, "engine", "online_moves", key="max_out_of_book_moves", default=10)
168
+ set_config_default(CONFIG, "engine", "online_moves", key="max_retries", default=2, force_empty_values=True)
169
+ set_config_default(CONFIG, "engine", "online_moves", key="max_depth", default=math.inf, force_empty_values=True)
170
+ set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="enabled", default=False)
171
+ set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="source", default="lichess")
172
+ set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="min_time", default=20)
173
+ set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="max_time", default=10800)
174
+ set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="max_pieces", default=7)
175
+ set_config_default(CONFIG, "engine", "online_moves", "online_egtb", key="move_quality", default="best")
176
+ set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="enabled", default=False)
177
+ set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_time", default=20)
178
+ set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="max_time", default=10800)
179
+ set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="move_quality", default="good")
180
+ set_config_default(CONFIG, "engine", "online_moves", "chessdb_book", key="min_depth", default=20)
181
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="enabled", default=False)
182
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_time", default=20)
183
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="max_time", default=10800)
184
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="move_quality", default="best")
185
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_depth", default=20)
186
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="min_knodes", default=0)
187
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_cloud_analysis", key="max_score_difference", default=50)
188
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="enabled", default=False)
189
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_time", default=20)
190
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="max_time", default=10800)
191
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="source", default="masters")
192
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="player_name", default="")
193
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="sort", default="winrate")
194
+ set_config_default(CONFIG, "engine", "online_moves", "lichess_opening_explorer", key="min_games", default=10)
195
+ set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="enabled", default=False)
196
+ set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="max_pieces", default=7)
197
+ set_config_default(CONFIG, "engine", "lichess_bot_tbs", "syzygy", key="move_quality", default="best")
198
+ set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="enabled", default=False)
199
+ set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="max_pieces", default=5)
200
+ set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="move_quality", default="best")
201
+ set_config_default(CONFIG, "engine", "lichess_bot_tbs", "gaviota", key="min_dtm_to_consider_as_wdl_1", default=120)
202
+ set_config_default(CONFIG, "engine", "polyglot", key="enabled", default=False)
203
+ set_config_default(CONFIG, "engine", "polyglot", key="max_depth", default=8)
204
+ set_config_default(CONFIG, "engine", "polyglot", key="selection", default="weighted_random")
205
+ set_config_default(CONFIG, "engine", "polyglot", key="min_weight", default=1)
206
+ set_config_default(CONFIG, "engine", "polyglot", key="normalization", default="none")
207
+ set_config_default(CONFIG, "challenge", key="concurrency", default=1)
208
+ set_config_default(CONFIG, "challenge", key="sort_by", default="best")
209
+ set_config_default(CONFIG, "challenge", key="preference", default="none")
210
+ set_config_default(CONFIG, "challenge", key="accept_bot", default=False)
211
+ set_config_default(CONFIG, "challenge", key="only_bot", default=False)
212
+ set_config_default(CONFIG, "challenge", key="max_increment", default=180)
213
+ set_config_default(CONFIG, "challenge", key="min_increment", default=0)
214
+ set_config_default(CONFIG, "challenge", key="max_base", default=math.inf)
215
+ set_config_default(CONFIG, "challenge", key="min_base", default=0)
216
+ set_config_default(CONFIG, "challenge", key="max_days", default=math.inf)
217
+ set_config_default(CONFIG, "challenge", key="min_days", default=1)
218
+ set_config_default(CONFIG, "challenge", key="block_list", default=[], force_empty_values=True)
219
+ set_config_default(CONFIG, "challenge", key="online_block_list", default=[], force_empty_values=True)
220
+ set_config_default(CONFIG, "challenge", key="allow_list", default=[], force_empty_values=True)
221
+ set_config_default(CONFIG, "challenge", key="min_rating", default=0, force_empty_values=True)
222
+ set_config_default(CONFIG, "challenge", key="max_rating", default=4000, force_empty_values=True)
223
+ set_config_default(CONFIG, "challenge", key="rating_difference", default=None)
224
+ set_config_default(CONFIG, "challenge", key="max_simultaneous_games_per_user", default=5)
225
+ set_config_default(CONFIG, "correspondence", key="checkin_period", default=600)
226
+ set_config_default(CONFIG, "correspondence", key="move_time", default=60, force_empty_values=True)
227
+ set_config_default(CONFIG, "correspondence", key="disconnect_time", default=300)
228
+ set_config_default(CONFIG, "matchmaking", key="challenge_timeout", default=30, force_empty_values=True)
229
+ CONFIG["matchmaking"]["challenge_timeout"] = max(CONFIG["matchmaking"]["challenge_timeout"], 1)
230
+ set_config_default(CONFIG, "matchmaking", key="block_list", default=[], force_empty_values=True)
231
+ set_config_default(CONFIG, "matchmaking", key="online_block_list", default=[], force_empty_values=True)
232
+ set_config_default(CONFIG, "matchmaking", key="include_challenge_block_list", default=False, force_empty_values=True)
233
+ default_filter = (CONFIG.get("matchmaking") or {}).get("delay_after_decline") or FilterType.NONE.value
234
+ set_config_default(CONFIG, "matchmaking", key="challenge_filter", default=default_filter, force_empty_values=True)
235
+ set_config_default(CONFIG, "matchmaking", key="allow_matchmaking", default=False)
236
+ set_config_default(CONFIG, "matchmaking", key="challenge_initial_time", default=[None], force_empty_values=True)
237
+ change_value_to_list(CONFIG, "matchmaking", key="challenge_initial_time")
238
+ set_config_default(CONFIG, "matchmaking", key="challenge_increment", default=[None], force_empty_values=True)
239
+ change_value_to_list(CONFIG, "matchmaking", key="challenge_increment")
240
+ set_config_default(CONFIG, "matchmaking", key="challenge_days", default=[None], force_empty_values=True)
241
+ change_value_to_list(CONFIG, "matchmaking", key="challenge_days")
242
+ set_config_default(CONFIG, "matchmaking", key="opponent_min_rating", default=600, force_empty_values=True)
243
+ set_config_default(CONFIG, "matchmaking", key="opponent_max_rating", default=4000, force_empty_values=True)
244
+ set_config_default(CONFIG, "matchmaking", key="rating_preference", default="none")
245
+ set_config_default(CONFIG, "matchmaking", key="challenge_variant", default="random")
246
+ set_config_default(CONFIG, "matchmaking", key="challenge_mode", default="random")
247
+ set_config_default(CONFIG, "matchmaking", key="overrides", default={}, force_empty_values=True)
248
+ for override_config in CONFIG["matchmaking"]["overrides"].values():
249
+ for parameter in ["challenge_initial_time", "challenge_increment", "challenge_days"]:
250
+ if parameter in override_config:
251
+ set_config_default(override_config, key=parameter, default=[None], force_empty_values=True)
252
+ change_value_to_list(override_config, key=parameter)
253
+
254
+ for section in ["engine", "correspondence"]:
255
+ for ponder in ["ponder", "uci_ponder"]:
256
+ set_config_default(CONFIG, section, key=ponder, default=False)
257
+
258
+ for greeting in ["hello", "goodbye"]:
259
+ for target in ["", "_spectators"]:
260
+ set_config_default(CONFIG, "greeting", key=greeting + target, default="", force_empty_values=True)
261
+
262
+
263
+ def process_block_list(CONFIG: CONFIG_DICT_TYPE) -> None:
264
+ """
265
+ Retrieve online block lists and copy over challenge blocklist if necessary.
266
+
267
+ :param CONFIG: The bot's config.
268
+ """
269
+ if CONFIG["matchmaking"]["include_challenge_block_list"]:
270
+ CONFIG["matchmaking"]["block_list"].extend(CONFIG["challenge"]["block_list"])
271
+
272
+
273
+ def log_config(CONFIG: CONFIG_DICT_TYPE, alternate_log_function: Callable[[str], Any] | None = None) -> None:
274
+ """
275
+ Log the config to make debugging easier.
276
+
277
+ :param CONFIG: The bot's config.
278
+ """
279
+ logger_config = CONFIG.copy()
280
+ logger_config["token"] = "logger" # noqa: S105 (Possible hardcoded password)
281
+ destination = alternate_log_function or logger.debug
282
+ destination(f"Config:\n{yaml.dump(logger_config, sort_keys=False)}")
283
+ destination("====================")
284
+
285
+
286
+ def validate_config(CONFIG: CONFIG_DICT_TYPE) -> None:
287
+ """Check if the config is valid."""
288
+ check_config_section(CONFIG, "token", str)
289
+ check_config_section(CONFIG, "url", str)
290
+ check_config_section(CONFIG, "engine", dict)
291
+ check_config_section(CONFIG, "challenge", dict)
292
+ check_config_section(CONFIG, "dir", str, "engine")
293
+ check_config_section(CONFIG, "name", str, "engine")
294
+
295
+ config_assert(os.path.isdir(CONFIG["engine"]["dir"]),
296
+ f'Your engine directory `{CONFIG["engine"]["dir"]}` is not a directory.')
297
+
298
+ working_dir = CONFIG["engine"].get("working_dir")
299
+ config_assert(not working_dir or os.path.isdir(working_dir),
300
+ f"Your engine's working directory `{working_dir}` is not a directory.")
301
+
302
+ engine = os.path.join(CONFIG["engine"]["dir"], CONFIG["engine"]["name"])
303
+ config_assert(os.path.isfile(engine) or CONFIG["engine"]["protocol"] == "homemade",
304
+ f"The engine {engine} file does not exist.")
305
+ config_assert(os.access(engine, os.X_OK) or CONFIG["engine"]["protocol"] == "homemade",
306
+ f"The engine {engine} doesn't have execute (x) permission. Try: chmod +x {engine}")
307
+
308
+ if CONFIG["engine"]["protocol"] == "xboard":
309
+ for section, subsection in (("online_moves", "online_egtb"),
310
+ ("lichess_bot_tbs", "syzygy"),
311
+ ("lichess_bot_tbs", "gaviota")):
312
+ online_section = (CONFIG["engine"].get(section) or {}).get(subsection) or {}
313
+ config_assert(online_section.get("move_quality") != "suggest" or not online_section.get("enabled"),
314
+ f"XBoard engines can't be used with `move_quality` set to `suggest` in {subsection}.")
315
+
316
+ config_warn(CONFIG["challenge"]["concurrency"] > 0, "With challenge.concurrency set to 0, the bot won't accept or create "
317
+ "any challenges.")
318
+
319
+ config_assert(CONFIG["challenge"]["sort_by"] in ["best", "first"], "challenge.sort_by can be either `first` or `best`.")
320
+ config_assert(CONFIG["challenge"]["preference"] in ["none", "human", "bot"],
321
+ "challenge.preference should be `none`, `human`, or `bot`.")
322
+
323
+ min_max_template = ("challenge.max_{setting} < challenge.min_{setting} will result "
324
+ "in no {game_type} challenges being accepted.")
325
+ for setting in ["increment", "base", "days"]:
326
+ game_type = "correspondence" if setting == "days" else "real-time"
327
+ config_warn(CONFIG["challenge"][f"min_{setting}"] <= CONFIG["challenge"][f"max_{setting}"],
328
+ min_max_template.format(setting=setting, game_type=game_type))
329
+
330
+ config_warn(CONFIG["challenge"]["min_rating"] <= CONFIG["challenge"]["max_rating"],
331
+ "challenge.max_rating < challenge.min_rating will result in no challenges being accepted.")
332
+ config_warn(CONFIG["challenge"].get("rating_difference") is None
333
+ or CONFIG["challenge"].get("rating_difference", 0) >= 0,
334
+ "challenge.rating_difference < 0 will result in no challenges being accepted.")
335
+
336
+ matchmaking = CONFIG["matchmaking"]
337
+ matchmaking_enabled = matchmaking["allow_matchmaking"]
338
+
339
+ if matchmaking_enabled:
340
+ config_warn(matchmaking["opponent_min_rating"] <= matchmaking["opponent_max_rating"],
341
+ "matchmaking.opponent_max_rating < matchmaking.opponent_min_rating will result in "
342
+ "no challenges being created.")
343
+ config_warn(matchmaking.get("opponent_rating_difference", 0) >= 0,
344
+ "matchmaking.opponent_rating_difference < 0 will result in no challenges being created.")
345
+ max_games_per_day = 100
346
+ game_timeout = minutes(matchmaking["challenge_timeout"])
347
+ config_warn(game_timeout*max_games_per_day >= days(1),
348
+ f"A bot is only allowed to play {max_games_per_day} games per day against other bots. Please check your "
349
+ "config file to make sure your bot won't use up all its allotted games quickly.")
350
+
351
+ pgn_directory = CONFIG["pgn_directory"]
352
+ in_docker = os.environ.get("LICHESS_BOT_DOCKER")
353
+ config_warn(not pgn_directory or not in_docker,
354
+ f"Games will be saved to '{pgn_directory}', please ensure this folder is in a mounted "
355
+ "volume; Using the Docker's container internal file system will prevent "
356
+ "you accessing the saved files and can lead to disk "
357
+ "saturation.")
358
+
359
+ valid_pgn_grouping_options = ["game", "opponent", "all"]
360
+ config_pgn_choice = CONFIG["pgn_file_grouping"]
361
+ config_assert(config_pgn_choice in valid_pgn_grouping_options,
362
+ f"The `pgn_file_grouping` choice of `{config_pgn_choice}` is not valid. "
363
+ f"Please choose from {valid_pgn_grouping_options}.")
364
+
365
+ def has_valid_list(name: str) -> bool:
366
+ entries = matchmaking.get(name)
367
+ return isinstance(entries, list) and entries[0] is not None
368
+ matchmaking_has_values = (has_valid_list("challenge_initial_time")
369
+ and has_valid_list("challenge_increment")
370
+ or has_valid_list("challenge_days"))
371
+ config_assert(not matchmaking_enabled or matchmaking_has_values,
372
+ "The time control to challenge other bots is not set. Either lists of challenge_initial_time and "
373
+ "challenge_increment is required, or a list of challenge_days, or both.")
374
+
375
+ filter_option = "challenge_filter"
376
+ filter_type = matchmaking.get(filter_option)
377
+ config_assert(filter_type is None or filter_type in FilterType.__members__.values(),
378
+ f"{filter_type} is not a valid value for {filter_option} (formerly delay_after_decline) parameter. "
379
+ f"Choices are: {', '.join(FilterType)}.")
380
+
381
+ config_assert(matchmaking.get("rating_preference") in ["none", "high", "low"],
382
+ f"{matchmaking.get('rating_preference')} is not a valid `matchmaking:rating_preference` option. "
383
+ f"Valid options are 'none', 'high', or 'low'.")
384
+
385
+ selection_choices = {"polyglot": ["weighted_random", "uniform_random", "best_move"],
386
+ "chessdb_book": ["all", "good", "best"],
387
+ "lichess_cloud_analysis": ["good", "best"],
388
+ "online_egtb": ["best", "suggest"]}
389
+ for db_name, valid_selections in selection_choices.items():
390
+ is_online = db_name != "polyglot"
391
+ db_section = (CONFIG["engine"].get("online_moves") or {}) if is_online else CONFIG["engine"]
392
+ db_config = db_section.get(db_name) or {}
393
+ select_key = "selection" if db_name == "polyglot" else "move_quality"
394
+ selection = db_config.get(select_key)
395
+ select = f"{'online_moves:' if is_online else ''}{db_name}:{select_key}"
396
+ config_assert(selection in valid_selections,
397
+ f"`{selection}` is not a valid `engine:{select}` value. "
398
+ f"Please choose from {valid_selections}.")
399
+
400
+ polyglot_section = CONFIG["engine"].get("polyglot") or {}
401
+ config_assert(polyglot_section.get("normalization") in ["none", "max", "sum"],
402
+ f"`{polyglot_section.get('normalization')}` is not a valid choice for "
403
+ f"`engine:polyglot:normalization`. Please choose from ['none', 'max', 'sum'].")
404
+
405
+ lichess_tbs_config = CONFIG["engine"].get("lichess_bot_tbs") or {}
406
+ quality_selections = ["best", "suggest"]
407
+ for tb in ["syzygy", "gaviota"]:
408
+ selection = (lichess_tbs_config.get(tb) or {}).get("move_quality")
409
+ config_assert(selection in quality_selections,
410
+ f"`{selection}` is not a valid choice for `engine:lichess_bot_tbs:{tb}:move_quality`. "
411
+ f"Please choose from {quality_selections}.")
412
+
413
+ explorer_choices = {"source": ["lichess", "masters", "player"],
414
+ "sort": ["winrate", "games_played"]}
415
+ explorer_config = (CONFIG["engine"].get("online_moves") or {}).get("lichess_opening_explorer")
416
+ if explorer_config:
417
+ for parameter, choice_list in explorer_choices.items():
418
+ explorer_choice = explorer_config.get(parameter)
419
+ config_assert(explorer_choice in choice_list,
420
+ f"`{explorer_choice}` is not a valid"
421
+ f" `engine:online_moves:lichess_opening_explorer:{parameter}`"
422
+ f" value. Please choose from {choice_list}.")
423
+
424
+
425
+ def load_config(config_file: str) -> Configuration:
426
+ """
427
+ Read the config.
428
+
429
+ :param config_file: The filename of the config (usually `config.yml`).
430
+ :return: A `Configuration` object containing the config.
431
+ """
432
+ with open(config_file) as stream:
433
+ try:
434
+ CONFIG = yaml.safe_load(stream)
435
+ except Exception:
436
+ logger.exception("There appears to be a syntax problem with your config.yml")
437
+ raise
438
+
439
+ log_config(CONFIG)
440
+
441
+ if "LICHESS_BOT_TOKEN" in os.environ:
442
+ CONFIG["token"] = os.environ["LICHESS_BOT_TOKEN"]
443
+
444
+ insert_default_values(CONFIG)
445
+ process_block_list(CONFIG)
446
+ log_config(CONFIG)
447
+ validate_config(CONFIG)
448
+
449
+ return Configuration(CONFIG)
lichess-bot-master/lib/conversation.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from typing import TypeAlias
10
+ MULTIPROCESSING_LIST_TYPE: TypeAlias = Sequence[model.Challenge]
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ChatLine:
16
+ """Information about the message."""
17
+
18
+ def __init__(self, message_info: GameEventType) -> None:
19
+ """Information about the message."""
20
+ self.room = message_info["room"]
21
+ """Whether the message was sent in the chat room or in the spectator room."""
22
+ self.username = message_info["username"]
23
+ """The username of the account that sent the message."""
24
+ self.text = message_info["text"]
25
+ """The message sent."""
26
+
27
+
28
+ class Conversation:
29
+ """Enables the bot to communicate with its opponent and the spectators."""
30
+
31
+ def __init__(self, game: model.Game, engine: EngineWrapper, li: Lichess, version: str,
32
+ challenge_queue: MULTIPROCESSING_LIST_TYPE) -> None:
33
+ """
34
+ Communication between lichess-bot and the game chats.
35
+
36
+ :param game: The game that the bot will send messages to.
37
+ :param engine: The engine playing the game.
38
+ :param li: A class that is used for communication with lichess.
39
+ :param version: The lichess-bot version.
40
+ :param challenge_queue: The active challenges the bot has.
41
+ """
42
+ self.game = game
43
+ self.engine = engine
44
+ self.li = li
45
+ self.version = version
46
+ self.challengers = challenge_queue
47
+ self.messages: list[ChatLine] = []
48
+
49
+ command_prefix = "!"
50
+
51
+ def react(self, line: ChatLine) -> None:
52
+ """
53
+ React to a received message.
54
+
55
+ :param line: Information about the message.
56
+ """
57
+ self.messages.append(line)
58
+ logger.info(f"*** {self.game.url()} [{line.room}] {line.username}: {line.text}")
59
+ if line.text[0] == self.command_prefix:
60
+ self.command(line, line.text[1:].lower())
61
+
62
+ def command(self, line: ChatLine, cmd: str) -> None:
63
+ """
64
+ Reacts to the specific commands in the chat.
65
+
66
+ :param line: Information about the message.
67
+ :param cmd: The command to react to.
68
+ """
69
+ from_self = line.username == self.game.username
70
+ is_eval = cmd.startswith("eval")
71
+ if cmd in ("commands", "help"):
72
+ self.send_reply(line,
73
+ "Supported commands: !wait (wait a minute for my first move), !name, "
74
+ "!eval (or any text starting with !eval), !queue")
75
+ elif cmd == "wait" and self.game.is_abortable():
76
+ self.game.ping(seconds(60), seconds(120), seconds(120))
77
+ self.send_reply(line, "Waiting 60 seconds...")
78
+ elif cmd == "name":
79
+ name = self.game.me.name
80
+ self.send_reply(line, f"{name} running {self.engine.name()} (lichess-bot v{self.version})")
81
+ elif is_eval and (from_self or line.room == "spectator"):
82
+ stats = self.engine.get_stats(for_chat=True)
83
+ self.send_reply(line, ", ".join(stats))
84
+ elif is_eval:
85
+ self.send_reply(line, "I don't tell that to my opponent, sorry.")
86
+ elif cmd == "queue":
87
+ if self.challengers:
88
+ challengers = ", ".join([f"@{challenger.challenger.name}" for challenger in reversed(self.challengers)])
89
+ self.send_reply(line, f"Challenge queue: {challengers}")
90
+ else:
91
+ self.send_reply(line, "No challenges queued.")
92
+
93
+ def send_reply(self, line: ChatLine, reply: str) -> None:
94
+ """
95
+ Send the reply to the chat.
96
+
97
+ :param line: Information about the original message that we reply to.
98
+ :param reply: The reply to send.
99
+ """
100
+ logger.info(f"*** {self.game.url()} [{line.room}] {self.game.username}: {reply}")
101
+ self.li.chat(self.game.id, line.room, reply)
102
+
103
+ def send_message(self, room: str, message: str) -> None:
104
+ """Send the message to the chat."""
105
+ if message:
106
+ self.send_reply(ChatLine({"room": room, "username": "", "text": ""}), message)
lichess-bot-master/lib/engine_wrapper.py ADDED
@@ -0,0 +1,1427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provides communication with the engine."""
2
+ from __future__ import annotations
3
+ import os
4
+ import chess.engine
5
+ import chess.polyglot
6
+ import chess.syzygy
7
+ import chess.gaviota
8
+ import chess
9
+ import subprocess
10
+ import logging
11
+ import datetime
12
+ import time
13
+ import random
14
+ import math
15
+ import contextlib
16
+ from collections import Counter
17
+ from collections.abc import Callable
18
+ from lib import model, lichess
19
+ from lib.config import Configuration, change_value_to_list
20
+ from lib.timer import Timer, msec, seconds, msec_str, sec_str, to_seconds
21
+ from lib.lichess_types import (ReadableType, ChessDBMoveType, LichessEGTBMoveType, OPTIONS_GO_EGTB_TYPE, OPTIONS_TYPE,
22
+ COMMANDS_TYPE, MOVE, InfoStrDict, InfoDictKeys, InfoDictValue, GO_COMMANDS_TYPE, EGTPATH_TYPE,
23
+ ENGINE_INPUT_ARGS_TYPE, ENGINE_INPUT_KWARGS_TYPE)
24
+ from extra_game_handlers import game_specific_options
25
+ from operator import itemgetter
26
+ from typing import Any, Literal, cast
27
+ from types import TracebackType
28
+
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ out_of_online_opening_book_moves: Counter[str] = Counter()
33
+
34
+
35
+ def create_engine(engine_config: Configuration, game: model.Game | None = None) -> EngineWrapper:
36
+ """
37
+ Create the engine.
38
+
39
+ Use in a with-block to automatically close the engine when exiting the game.
40
+
41
+ :param engine_config: The options for the engine.
42
+ :param game: The game to create the engine from.
43
+ :return: An engine. Either UCI, XBoard, or Homemade.
44
+ """
45
+ cfg = engine_config.engine
46
+ engine_path = os.path.abspath(os.path.join(cfg.dir, cfg.name))
47
+ engine_type = cfg.protocol
48
+ commands = []
49
+ if cfg.interpreter:
50
+ commands.append(cfg.interpreter)
51
+ commands.extend(cfg.interpreter_options)
52
+ commands.append(engine_path)
53
+ if cfg.engine_options:
54
+ for k, v in cfg.engine_options.items():
55
+ commands.append(f"--{k}={v}" if v is not None else f"--{k}")
56
+
57
+ stderr = subprocess.DEVNULL if cfg.silence_stderr else None
58
+
59
+ Engine: type[UCIEngine | XBoardEngine | MinimalEngine]
60
+ if engine_type == "xboard":
61
+ Engine = XBoardEngine
62
+ elif engine_type == "uci":
63
+ Engine = UCIEngine
64
+ elif engine_type == "homemade":
65
+ Engine = get_homemade_engine(cfg.name)
66
+ else:
67
+ raise ValueError(
68
+ f" Invalid engine type: {engine_type}. Expected xboard, uci, or homemade.")
69
+ options = remove_managed_options(cfg.lookup(f"{engine_type}_options") or Configuration({}))
70
+ logger.debug(f"Starting engine: {commands}")
71
+ return Engine(commands, options, stderr, cfg.draw_or_resign, game, cfg.debug,
72
+ cwd=cfg.working_dir)
73
+
74
+
75
+ def remove_managed_options(config: Configuration) -> OPTIONS_GO_EGTB_TYPE:
76
+ """Remove the options managed by python-chess."""
77
+ def is_managed(key: str) -> bool:
78
+ return chess.engine.Option(key, "", None, None, None, None).is_managed()
79
+
80
+ return {name: value for (name, value) in config.items() if not is_managed(name)}
81
+
82
+
83
+ PONDERPV_CHARACTERS = 6 # The length of ", Pv: ".
84
+
85
+
86
+ class EngineWrapper:
87
+ """A wrapper used by all engines (UCI, XBoard, Homemade)."""
88
+
89
+ def __init__(self, options: OPTIONS_GO_EGTB_TYPE, draw_or_resign: Configuration) -> None:
90
+ """
91
+ Initialize the values of the wrapper used by all engines (UCI, XBoard, Homemade).
92
+
93
+ :param options: The options to send to the engine.
94
+ :param draw_or_resign: Options on whether the bot should resign or offer draws.
95
+ """
96
+ self.engine: chess.engine.SimpleEngine | FillerEngine
97
+ self.scores: list[chess.engine.PovScore] = []
98
+ self.draw_or_resign = draw_or_resign
99
+ self.go_commands = Configuration(cast(GO_COMMANDS_TYPE, options.pop("go_commands", {})) or {})
100
+ self.move_commentary: list[InfoStrDict] = []
101
+ self.comment_start_index = -1
102
+
103
+ def configure(self, options: OPTIONS_GO_EGTB_TYPE, game: model.Game | None) -> None:
104
+ """
105
+ Send configurations to the engine.
106
+
107
+ :param options: A dictionary of strings to option values.
108
+
109
+ Raises chess.engine.EngineError if an option is sent that the engine does not support.
110
+ """
111
+ try:
112
+ extra_options = {} if game is None else game_specific_options(game)
113
+ self.engine.configure(cast(OPTIONS_TYPE, options | extra_options))
114
+ except Exception:
115
+ self.engine.close()
116
+ raise
117
+
118
+ def __enter__(self) -> EngineWrapper: # noqa: PYI034 (return Self not available until 3.11)
119
+ """Enter context so engine communication will be properly shutdown."""
120
+ self.engine.__enter__()
121
+ return self
122
+
123
+ def __exit__(self, exc_type: type[BaseException] | None,
124
+ exc_value: BaseException | None,
125
+ traceback: TracebackType | None) -> None:
126
+ """Exit context and allow engine to shutdown nicely if there was no exception."""
127
+ if exc_type is None:
128
+ self.ping()
129
+ self.quit()
130
+ self.engine.__exit__(exc_type, exc_value, traceback)
131
+
132
+ def play_move(self,
133
+ board: chess.Board,
134
+ game: model.Game,
135
+ li: lichess.Lichess,
136
+ setup_timer: Timer,
137
+ move_overhead: datetime.timedelta,
138
+ can_ponder: bool,
139
+ is_correspondence: bool,
140
+ correspondence_move_time: datetime.timedelta,
141
+ engine_cfg: Configuration,
142
+ min_time: datetime.timedelta) -> None:
143
+ """
144
+ Play a move.
145
+
146
+ :param board: The current position.
147
+ :param game: The game that the bot is playing.
148
+ :param li: Provides communication with lichess.org.
149
+ :param start_time: The time that the bot received the move.
150
+ :param move_overhead: The time it takes to communicate between the engine and lichess.org.
151
+ :param can_ponder: Whether the engine is allowed to ponder.
152
+ :param is_correspondence: Whether this is a correspondence or unlimited game.
153
+ :param correspondence_move_time: The time the engine will think if `is_correspondence` is true.
154
+ :param engine_cfg: Options for external moves (e.g. from an opening book), and for engine resignation and draw offers.
155
+ :param min_time: Minimum time to spend, in seconds.
156
+ :return: The move to play.
157
+ """
158
+ polyglot_cfg = engine_cfg.polyglot
159
+ online_moves_cfg = engine_cfg.online_moves
160
+ draw_or_resign_cfg = engine_cfg.draw_or_resign
161
+ lichess_bot_tbs = engine_cfg.lichess_bot_tbs
162
+
163
+ best_move: MOVE
164
+ best_move = get_book_move(board, game, polyglot_cfg)
165
+
166
+ if best_move.move is None:
167
+ best_move = get_egtb_move(board,
168
+ game,
169
+ lichess_bot_tbs,
170
+ draw_or_resign_cfg)
171
+
172
+ if not isinstance(best_move, list) and best_move.move is None:
173
+ best_move = get_online_move(li,
174
+ board,
175
+ game,
176
+ online_moves_cfg,
177
+ draw_or_resign_cfg)
178
+
179
+ if isinstance(best_move, list) or best_move.move is None:
180
+ draw_offered = check_for_draw_offer(game)
181
+
182
+ time_limit, can_ponder = move_time(board, game, can_ponder,
183
+ setup_timer, move_overhead,
184
+ is_correspondence, correspondence_move_time)
185
+
186
+ try:
187
+ best_move = self.search(board, time_limit, can_ponder, draw_offered, best_move)
188
+ except chess.engine.EngineError as error:
189
+ BadMove = (chess.IllegalMoveError, chess.InvalidMoveError)
190
+ if not any(isinstance(e, BadMove) for e in error.args):
191
+ raise
192
+ logger.error("Ending game due to bot attempting an illegal move.")
193
+ logger.error(error)
194
+ game_ender = li.abort if game.is_abortable() else li.resign
195
+ game_ender(game.id)
196
+ return
197
+
198
+ # Heed min_time
199
+ elapsed = setup_timer.time_since_reset()
200
+ if elapsed < min_time:
201
+ time.sleep(to_seconds(min_time - elapsed))
202
+
203
+ self.add_comment(best_move, board)
204
+ self.print_stats()
205
+ if best_move.resigned and len(board.move_stack) >= 2:
206
+ li.resign(game.id)
207
+ else:
208
+ li.make_move(game.id, best_move)
209
+
210
+ def add_go_commands(self, time_limit: chess.engine.Limit) -> chess.engine.Limit:
211
+ """Add extra commands to send to the engine. For example, to search for 1000 nodes or up to depth 10."""
212
+ movetime_cfg = self.go_commands.movetime
213
+ if movetime_cfg is not None:
214
+ movetime = msec(movetime_cfg)
215
+ if time_limit.time is None or seconds(time_limit.time) > movetime:
216
+ time_limit.time = to_seconds(movetime)
217
+ time_limit.depth = self.go_commands.depth
218
+ time_limit.nodes = self.go_commands.nodes
219
+ return time_limit
220
+
221
+ def offer_draw_or_resign(self, result: chess.engine.PlayResult, board: chess.Board) -> chess.engine.PlayResult:
222
+ """Offer draw or resign depending on the score of the engine."""
223
+ def actual(score: chess.engine.PovScore) -> int:
224
+ return score.relative.score(mate_score=40000)
225
+
226
+ can_offer_draw = self.draw_or_resign.offer_draw_enabled
227
+ draw_offer_moves = self.draw_or_resign.offer_draw_moves
228
+ draw_score_range: int = self.draw_or_resign.offer_draw_score
229
+ draw_max_piece_count = self.draw_or_resign.offer_draw_pieces
230
+ pieces_on_board = chess.popcount(board.occupied)
231
+ enough_pieces_captured = pieces_on_board <= draw_max_piece_count
232
+ if can_offer_draw and len(self.scores) >= draw_offer_moves and enough_pieces_captured:
233
+ scores = self.scores[-draw_offer_moves:]
234
+
235
+ def score_near_draw(score: chess.engine.PovScore) -> bool:
236
+ return abs(actual(score)) <= draw_score_range
237
+ if len(scores) == len(list(filter(score_near_draw, scores))):
238
+ result.draw_offered = True
239
+
240
+ resign_enabled = self.draw_or_resign.resign_enabled
241
+ min_moves_for_resign = self.draw_or_resign.resign_moves
242
+ resign_score: int = self.draw_or_resign.resign_score
243
+ if resign_enabled and len(self.scores) >= min_moves_for_resign:
244
+ scores = self.scores[-min_moves_for_resign:]
245
+
246
+ def score_near_loss(score: chess.engine.PovScore) -> bool:
247
+ return actual(score) <= resign_score
248
+ if len(scores) == len(list(filter(score_near_loss, scores))):
249
+ result.resigned = True
250
+ return result
251
+
252
+ def search(self, board: chess.Board, time_limit: chess.engine.Limit, ponder: bool, draw_offered: bool,
253
+ root_moves: MOVE) -> chess.engine.PlayResult:
254
+ """
255
+ Tell the engine to search.
256
+
257
+ :param board: The current position.
258
+ :param time_limit: Conditions for how long the engine can search (e.g. we have 10 seconds and search up to depth 10).
259
+ :param ponder: Whether the engine can ponder.
260
+ :param draw_offered: Whether the bot was offered a draw.
261
+ :param root_moves: If it is a list, the engine will only play a move that is in `root_moves`.
262
+ :return: The move to play.
263
+ """
264
+ time_limit = self.add_go_commands(time_limit)
265
+ result = self.engine.play(board,
266
+ time_limit,
267
+ info=chess.engine.INFO_ALL,
268
+ ponder=ponder,
269
+ draw_offered=draw_offered,
270
+ root_moves=root_moves if isinstance(root_moves, list) else None)
271
+ # Use null_score to have no effect on draw/resign decisions
272
+ null_score = chess.engine.PovScore(chess.engine.Mate(1), board.turn)
273
+ self.scores.append(result.info.get("score", null_score))
274
+ return self.offer_draw_or_resign(result, board)
275
+
276
+ def comment_index(self, move_stack_index: int) -> int:
277
+ """
278
+ Get the index of a move for use in `comment_for_board_index`.
279
+
280
+ :param move_stack_index: The move number.
281
+ :return: The index of the move in `self.move_commentary`.
282
+ """
283
+ if self.comment_start_index < 0:
284
+ return -1
285
+ return move_stack_index - self.comment_start_index
286
+
287
+ def comment_for_board_index(self, index: int) -> InfoStrDict:
288
+ """
289
+ Get the engine comments for a specific move.
290
+
291
+ :param index: The move number.
292
+ :return: The move comments.
293
+ """
294
+ no_info: InfoStrDict = {}
295
+ comment_index = self.comment_index(index)
296
+ if comment_index < 0 or comment_index % 2 != 0:
297
+ return no_info
298
+
299
+ try:
300
+ return self.move_commentary[comment_index // 2]
301
+ except IndexError:
302
+ return no_info
303
+
304
+ def add_comment(self, move: chess.engine.PlayResult, board: chess.Board) -> None:
305
+ """
306
+ Store the move's comments.
307
+
308
+ :param move: The move. Contains the comments in `move.info`.
309
+ :param board: The current position.
310
+ """
311
+ if self.comment_start_index < 0:
312
+ self.comment_start_index = len(board.move_stack)
313
+ move_info = cast(InfoStrDict, dict(move.info.copy() if move.info else {}))
314
+ if "pv" in move_info:
315
+ move_info["ponderpv"] = board.variation_san(move.info["pv"])
316
+ if "refutation" in move_info:
317
+ move_info["refutation"] = board.variation_san(move.info["refutation"])
318
+ if "currmove" in move_info:
319
+ move_info["currmove"] = board.san(move.info["currmove"])
320
+ self.move_commentary.append(move_info)
321
+
322
+ def discard_last_move_commentary(self) -> None:
323
+ """
324
+ Remove the commentary for the last move, if any.
325
+
326
+ Used after allowing an opponent to take back a move.
327
+ """
328
+ with contextlib.suppress(IndexError):
329
+ self.move_commentary.pop()
330
+
331
+ def print_stats(self) -> None:
332
+ """Print the engine stats."""
333
+ for line in self.get_stats():
334
+ logger.info(line)
335
+
336
+ def readable_score(self, relative_score: chess.engine.PovScore) -> str:
337
+ """Convert the score to a more human-readable format."""
338
+ score = relative_score.relative
339
+ cp_score = score.score()
340
+ if cp_score is None:
341
+ str_score = f"#{score.mate()}"
342
+ else:
343
+ str_score = str(round(cp_score / 100, 2))
344
+ return str_score
345
+
346
+ def readable_wdl(self, wdl: chess.engine.PovWdl) -> str:
347
+ """Convert the WDL score to a percentage, so it is more human-readable."""
348
+ wdl_percentage = round(wdl.relative.expectation() * 100, 1)
349
+ return f"{wdl_percentage}%"
350
+
351
+ def readable_time(self, number: int) -> str:
352
+ """Convert time given as a number into minutes and seconds, so it is more human-readable. e.g. 123 -> 2m 3s."""
353
+ minutes, seconds = divmod(number, 60)
354
+ if minutes >= 1:
355
+ return f"{minutes:0.0f}m {seconds:0.1f}s"
356
+ else:
357
+ return f"{seconds:0.1f}s"
358
+
359
+ def readable_number(self, number: int) -> str:
360
+ """Convert number to a more human-readable format. e.g. 123456789 -> 123M."""
361
+ if number >= 1e9:
362
+ return f"{round(number / 1e9, 1)}B"
363
+ if number >= 1e6:
364
+ return f"{round(number / 1e6, 1)}M"
365
+ if number >= 1e3:
366
+ return f"{round(number / 1e3, 1)}K"
367
+ return str(number)
368
+
369
+ def to_readable_value(self, stat: InfoDictKeys, info: InfoStrDict) -> str:
370
+ """Change a value to a more human-readable format."""
371
+ readable: ReadableType = {"Evaluation": self.readable_score, "Winrate": self.readable_wdl,
372
+ "Hashfull": lambda x: f"{round(x / 10, 1)}%", "Nodes": self.readable_number,
373
+ "Speed": lambda x: f"{self.readable_number(x)}nps", "Tbhits": self.readable_number,
374
+ "Cpuload": lambda x: f"{round(x / 10, 1)}%", "Movetime": self.readable_time}
375
+
376
+ def identity(x: InfoDictValue) -> str:
377
+ return str(x)
378
+
379
+ func = cast(Callable[[InfoDictValue], str], readable.get(stat, identity))
380
+ return str(func(info[stat]))
381
+
382
+ def get_stats(self, for_chat: bool = False) -> list[str]:
383
+ """
384
+ Get the stats returned by the engine.
385
+
386
+ :param for_chat: Whether the stats will be sent to the game chat, which has a 140 character limit.
387
+ """
388
+ can_index = self.move_commentary and self.move_commentary[-1]
389
+ info: InfoStrDict = self.move_commentary[-1].copy() if can_index else {}
390
+
391
+ def to_readable_item(stat: InfoDictKeys, value: InfoDictValue) -> tuple[InfoDictKeys, InfoDictValue]:
392
+ readable = {"wdl": "winrate", "ponderpv": "PV", "nps": "speed", "score": "evaluation", "time": "movetime"}
393
+ stat = cast(InfoDictKeys, readable.get(stat, stat))
394
+ if stat == "string" and isinstance(value, str) and value.startswith("lichess-bot-source:"):
395
+ stat = "Source"
396
+ value = value.split(":", 1)[1]
397
+ return cast(InfoDictKeys, stat.title()), value
398
+
399
+ info = cast(InfoStrDict, dict(to_readable_item(cast(InfoDictKeys, key), cast(InfoDictValue, value))
400
+ for (key, value) in info.items()))
401
+ if "Source" not in info:
402
+ info["Source"] = "Engine"
403
+
404
+ stats = ["Source", "Evaluation", "Winrate", "Depth", "Nodes", "Speed", "Pv"]
405
+ if for_chat and "Pv" in info:
406
+ bot_stats = [f"{stat}: {self.to_readable_value(cast(InfoDictKeys, stat), info)}"
407
+ for stat in stats if stat in info and stat != "Pv"]
408
+ len_bot_stats = len(", ".join(bot_stats)) + PONDERPV_CHARACTERS
409
+ ponder_pv = info["Pv"].split()
410
+ try:
411
+ while len(" ".join(ponder_pv)) + len_bot_stats > lichess.MAX_CHAT_MESSAGE_LEN:
412
+ ponder_pv.pop()
413
+ if ponder_pv[-1].endswith("."):
414
+ ponder_pv.pop()
415
+ info["Pv"] = " ".join(ponder_pv)
416
+ except IndexError:
417
+ pass
418
+ if not info["Pv"]:
419
+ info.pop("Pv")
420
+ return [f"{stat}: {self.to_readable_value(cast(InfoDictKeys, stat), info)}" for stat in stats if stat in info]
421
+
422
+ def get_opponent_info(self, game: model.Game) -> None:
423
+ """Get the opponent's information and sends it to the engine."""
424
+ opponent = chess.engine.Opponent(name=game.opponent.name,
425
+ title=game.opponent.title,
426
+ rating=game.opponent.rating,
427
+ is_engine=game.opponent.is_bot)
428
+ self.engine.send_opponent_information(opponent=opponent, engine_rating=game.me.rating)
429
+
430
+ def name(self) -> str:
431
+ """Get the name of the engine."""
432
+ return self.engine.id["name"]
433
+
434
+ def get_pid(self) -> str:
435
+ """Get the pid of the engine."""
436
+ pid = "?"
437
+ if self.engine.transport is not None:
438
+ pid = str(self.engine.transport.get_pid())
439
+ return pid
440
+
441
+ def ping(self) -> None:
442
+ """Ping the engine."""
443
+ self.engine.ping()
444
+
445
+ def send_game_result(self, game: model.Game, board: chess.Board) -> None:
446
+ """
447
+ Inform engine of the game ending.
448
+
449
+ :param game: The final game state from lichess.
450
+ :param board: The final board state.
451
+ """
452
+ termination = game.state.get("status")
453
+ winner = game.state.get("winner")
454
+ winning_color = chess.WHITE if winner == "white" else chess.BLACK
455
+
456
+ if termination == model.Termination.MATE:
457
+ self.engine.send_game_result(board)
458
+ elif termination == model.Termination.RESIGN:
459
+ resigner = "White" if winner == "black" else "Black"
460
+ self.engine.send_game_result(board, winning_color, f"{resigner} resigned")
461
+ elif termination == model.Termination.ABORT:
462
+ self.engine.send_game_result(board, None, "Game aborted", False)
463
+ elif termination == model.Termination.DRAW:
464
+ draw_reason = None if board.is_game_over(claim_draw=True) else "Draw by agreement"
465
+ self.engine.send_game_result(board, None, draw_reason)
466
+ elif termination == model.Termination.TIMEOUT:
467
+ if winner:
468
+ self.engine.send_game_result(board, winning_color, "Time forfeiture")
469
+ else:
470
+ self.engine.send_game_result(board, None, "Time out with insufficient material")
471
+ else:
472
+ self.engine.send_game_result(board, None, termination)
473
+
474
+ def quit(self) -> None:
475
+ """Tell the engine to shut down."""
476
+ self.engine.quit()
477
+
478
+
479
+ class UCIEngine(EngineWrapper):
480
+ """The class used to communicate with UCI engines."""
481
+
482
+ def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None,
483
+ draw_or_resign: Configuration, game: model.Game | None, debug: bool,
484
+ **popen_args: str) -> None:
485
+ """
486
+ Communicate with UCI engines.
487
+
488
+ :param commands: The engine path and commands to send to the engine. e.g. ["engines/engine.exe", "--option1=value1"]
489
+ :param options: The options to send to the engine.
490
+ :param stderr: Whether we should silence the stderr.
491
+ :param draw_or_resign: Options on whether the bot should resign or offer draws.
492
+ :param game: The first Game message from the game stream.
493
+ :param debug: Whether to debug or not.
494
+ :param popen_args: The cwd of the engine.
495
+ """
496
+ super().__init__(options, draw_or_resign)
497
+ self.engine = chess.engine.SimpleEngine.popen_uci(commands, timeout=60., debug=debug, setpgrp=True, stderr=stderr,
498
+ **popen_args)
499
+ self.configure(options, game)
500
+
501
+
502
+ class XBoardEngine(EngineWrapper):
503
+ """The class used to communicate with XBoard engines."""
504
+
505
+ def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None,
506
+ draw_or_resign: Configuration, game: model.Game | None, debug: bool,
507
+ **popen_args: str) -> None:
508
+ """
509
+ Communicate with XBoard engines.
510
+
511
+ :param commands: The engine path and commands to send to the engine. e.g. ["engines/engine.exe", "--option1=value1"]
512
+ :param options: The options to send to the engine.
513
+ :param stderr: Whether we should silence the stderr.
514
+ :param draw_or_resign: Options on whether the bot should resign or offer draws.
515
+ :param game: The first Game message from the game stream.
516
+ :param debug: Whether to debug or not.
517
+ :param popen_args: The cwd of the engine.
518
+ """
519
+ super().__init__(options, draw_or_resign)
520
+ self.engine = chess.engine.SimpleEngine.popen_xboard(commands, timeout=60., debug=debug, setpgrp=True,
521
+ stderr=stderr, **popen_args)
522
+ egt_paths = cast(EGTPATH_TYPE, options.pop("egtpath", {}) or {})
523
+ protocol = cast(chess.engine.XBoardProtocol, self.engine.protocol)
524
+ egt_features = protocol.features.get("egt", "")
525
+ if isinstance(egt_features, str):
526
+ egt_types_from_engine = egt_features.split(",")
527
+ for egt_type in filter(None, egt_types_from_engine):
528
+ if egt_type in egt_paths:
529
+ options[f"egtpath {egt_type}"] = egt_paths[egt_type]
530
+ else:
531
+ logger.debug(f"No paths found for egt type: {egt_type}.")
532
+ self.configure(options, game)
533
+
534
+
535
+ class MinimalEngine(EngineWrapper):
536
+ """
537
+ Subclass this to prevent a few random errors.
538
+
539
+ Even though MinimalEngine extends EngineWrapper,
540
+ you don't have to actually wrap an engine.
541
+
542
+ At minimum, just implement `search`,
543
+ however you can also change other methods like
544
+ `notify`, etc.
545
+ """
546
+
547
+ def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None, # noqa: ARG002
548
+ draw_or_resign: Configuration, game: model.Game | None, debug: bool, # noqa: ARG002
549
+ **popen_args: str) -> None: # noqa: ARG002 Unused argument popen_args
550
+ """
551
+ Initialize the values of the engine that all homemade engines inherit.
552
+
553
+ :param options: The options to send to the engine.
554
+ :param draw_or_resign: Options on whether the bot should resign or offer draws.
555
+ """
556
+ super().__init__(options, draw_or_resign)
557
+ self.engine_name = self.__class__.__name__
558
+ self.engine = FillerEngine(self, name=self.engine_name)
559
+
560
+ def get_pid(self) -> str:
561
+ """Homemade engines don't have a pid, so we return a question mark."""
562
+ return "?"
563
+
564
+ def search(self, board: chess.Board, time_limit: chess.engine.Limit, ponder: bool, draw_offered: bool,
565
+ root_moves: MOVE) -> chess.engine.PlayResult:
566
+ """
567
+ Choose a move.
568
+
569
+ The method to be implemented in your homemade engine.
570
+ NOTE: This method must return an instance of "chess.engine.PlayResult"
571
+ """
572
+ raise NotImplementedError("The search method is not implemented")
573
+
574
+ def notify(self, method_name: str, *args: ENGINE_INPUT_ARGS_TYPE, **kwargs: ENGINE_INPUT_KWARGS_TYPE
575
+ ) -> Any:
576
+ """
577
+ Enable the use of `self.engine.option1`.
578
+
579
+ The EngineWrapper class sometimes calls methods on "self.engine".
580
+
581
+ "self.engine" is a filler property that notifies <self>
582
+ whenever an attribute is called.
583
+
584
+ Nothing happens unless the main engine does something.
585
+
586
+ Simply put, the following code is equivalent
587
+ self.engine.<method_name>(<*args>, <**kwargs>)
588
+ self.notify(<method_name>, <*args>, <**kwargs>)
589
+ """
590
+
591
+
592
+ class FillerEngine:
593
+ """
594
+ Not meant to be an actual engine.
595
+
596
+ This is only used to provide the property "self.engine"
597
+ in "MinimalEngine" which extends "EngineWrapper"
598
+ """
599
+
600
+ def __init__(self, main_engine: MinimalEngine, name: str = "") -> None:
601
+ """:param name: The name to send to the chat."""
602
+ self.id = {"name": name}
603
+ self.name = name
604
+ self.main_engine = main_engine
605
+
606
+ def __getattr__(self, method_name: str) -> Any:
607
+ """Provide the property `self.engine`."""
608
+ main_engine = self.main_engine
609
+
610
+ # These types aren't tested by mypy.
611
+ def method(*args: ENGINE_INPUT_ARGS_TYPE, **kwargs: ENGINE_INPUT_KWARGS_TYPE) -> Any:
612
+ nonlocal main_engine
613
+ nonlocal method_name
614
+ return main_engine.notify(method_name, *args, **kwargs)
615
+
616
+ return method
617
+
618
+
619
+ test_suffix = "-for-lichess-bot-testing-only"
620
+
621
+
622
+ def get_homemade_engine(name: str) -> type[MinimalEngine]:
623
+ """
624
+ Get the homemade engine with name `name`. e.g. If `name` is `RandomMove` then we will return `homemade.RandomMove`.
625
+
626
+ :param name: The name of the homemade engine.
627
+ :return: The engine with this name.
628
+ """
629
+ import homemade
630
+ from test_bot import homemade as test_homemade
631
+ engine: type[MinimalEngine]
632
+ if name.endswith(test_suffix): # Test only.
633
+ engine = getattr(test_homemade, name.removesuffix(test_suffix))
634
+ else:
635
+ engine = getattr(homemade, name)
636
+ return engine
637
+
638
+
639
+ def move_time(board: chess.Board,
640
+ game: model.Game,
641
+ can_ponder: bool,
642
+ setup_timer: Timer,
643
+ move_overhead: datetime.timedelta,
644
+ is_correspondence: bool,
645
+ correspondence_move_time: datetime.timedelta) -> tuple[chess.engine.Limit, bool]:
646
+ """
647
+ Determine the game clock settings for the current move.
648
+
649
+ :param Board: The current position.
650
+ :param game: Information about the current game.
651
+ :param setup_timer: How much time has passed since receiving the opponent's move.
652
+ :param move_overhead: How much time it takes to communicate with lichess.
653
+ :param can_ponder: Whether the bot is allowed to ponder after choosing a move.
654
+ :param is_correspondence: Whether the current game is a correspondence game.
655
+ :param correspondence_move_time: How much time to use for this move it it is a correspondence game.
656
+ :return: The time to choose a move and whether the bot can ponder after the move.
657
+ """
658
+ if len(board.move_stack) < 2:
659
+ return first_move_time(game), False # No pondering after the first move since a new clock starts afterwards.
660
+ if is_correspondence:
661
+ return single_move_time(board, game, correspondence_move_time, setup_timer, move_overhead), can_ponder
662
+ return game_clock_time(board, game, setup_timer, move_overhead), can_ponder
663
+
664
+
665
+ def wbtime(board: chess.Board) -> Literal["wtime", "btime"]:
666
+ """Return `wtime` if it is white's turn to move else `btime`."""
667
+ return "wtime" if board.turn == chess.WHITE else "btime"
668
+
669
+
670
+ def wbinc(board: chess.Board) -> Literal["winc", "binc"]:
671
+ """Return `winc` if it is white's turn to move else `binc`."""
672
+ return "winc" if board.turn == chess.WHITE else "binc"
673
+
674
+
675
+ def single_move_time(board: chess.Board, game: model.Game, search_time: datetime.timedelta,
676
+ setup_timer: Timer, move_overhead: datetime.timedelta) -> chess.engine.Limit:
677
+ """
678
+ Calculate time to search in correspondence games.
679
+
680
+ :param board: The current positions.
681
+ :param game: The game that the bot is playing.
682
+ :param search_time: How long the engine should search.
683
+ :param setup_timer: How much time has passed since receiving the opponent's move.
684
+ :param move_overhead: The time it takes to communicate between the engine and lichess-bot.
685
+ :return: The time to choose a move.
686
+ """
687
+ pre_move_time = setup_timer.time_since_reset()
688
+ overhead = pre_move_time + move_overhead
689
+ clock_time = max(msec(1), msec(game.state[wbtime(board)]) - overhead)
690
+ search_time = min(search_time, clock_time)
691
+ logger.info(f"Searching for time {sec_str(search_time)} seconds for game {game.id}")
692
+ return chess.engine.Limit(time=to_seconds(search_time), clock_id="correspondence")
693
+
694
+
695
+ def first_move_time(game: model.Game) -> chess.engine.Limit:
696
+ """
697
+ Determine time limit for the first move in the game.
698
+
699
+ :param game: The game that the bot is playing.
700
+ :return: The time to choose the first move.
701
+ """
702
+ # Need to hardcode first movetime since Lichess has 30 sec limit.
703
+ search_time = seconds(10)
704
+ logger.info(f"Searching for time {sec_str(search_time)} seconds for game {game.id}")
705
+ return chess.engine.Limit(time=to_seconds(search_time), clock_id="first move")
706
+
707
+
708
+ def game_clock_time(board: chess.Board,
709
+ game: model.Game,
710
+ setup_timer: Timer,
711
+ move_overhead: datetime.timedelta) -> chess.engine.Limit:
712
+ """
713
+ Get the time to play by the engine in realtime games.
714
+
715
+ :param board: The current positions.
716
+ :param game: The game that the bot is playing.
717
+ :param setup_timer: How much time has passed since receiving the opponent's move.
718
+ :param move_overhead: The time it takes to communicate between the engine and lichess-bot.
719
+ :return: The time to play a move.
720
+ """
721
+ pre_move_time = setup_timer.time_since_reset()
722
+ overhead = pre_move_time + move_overhead
723
+ times = {"wtime": msec(game.state["wtime"]), "btime": msec(game.state["btime"])}
724
+ side = wbtime(board)
725
+ times[side] = max(msec(1), times[side] - overhead)
726
+ logger.info(f"Searching for wtime {msec_str(times['wtime'])} btime {msec_str(times['btime'])} for game {game.id}")
727
+ return chess.engine.Limit(white_clock=to_seconds(times["wtime"]),
728
+ black_clock=to_seconds(times["btime"]),
729
+ white_inc=to_seconds(msec(game.state["winc"])),
730
+ black_inc=to_seconds(msec(game.state["binc"])),
731
+ clock_id="real time")
732
+
733
+
734
+ def check_for_draw_offer(game: model.Game) -> bool:
735
+ """Check if the bot was offered a draw."""
736
+ return bool(game.state.get(f"{game.opponent_color[0]}draw"))
737
+
738
+
739
+ def get_book_move(board: chess.Board, game: model.Game,
740
+ polyglot_cfg: Configuration) -> chess.engine.PlayResult:
741
+ """Get a move from an opening book."""
742
+ no_book_move = chess.engine.PlayResult(None, None)
743
+ use_book = polyglot_cfg.enabled
744
+ max_game_length = polyglot_cfg.max_depth * 2 - 1
745
+ if not use_book or len(board.move_stack) > max_game_length:
746
+ return no_book_move
747
+
748
+ if board.chess960:
749
+ variant = "chess960"
750
+ else:
751
+ variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant)
752
+
753
+ change_value_to_list(polyglot_cfg.config, "book", key=variant)
754
+ books = polyglot_cfg.book.lookup(variant)
755
+
756
+ for book in books:
757
+ with chess.polyglot.open_reader(book) as reader:
758
+ try:
759
+ selection = polyglot_cfg.selection
760
+ min_weight = polyglot_cfg.min_weight
761
+ normalization = polyglot_cfg.normalization
762
+ weights = [entry.weight for entry in reader.find_all(board)]
763
+ scalar = (sum(weights) if normalization == "sum" and weights else
764
+ max(weights) if normalization == "max" and weights else 100)
765
+ min_weight = min_weight * scalar / 100
766
+
767
+ if selection == "weighted_random":
768
+ move = reader.weighted_choice(board).move
769
+ elif selection == "uniform_random":
770
+ move = reader.choice(board, minimum_weight=min_weight).move
771
+ elif selection == "best_move":
772
+ move = reader.find(board, minimum_weight=min_weight).move
773
+ except IndexError:
774
+ # python-chess raises "IndexError" if no entries found.
775
+ move = None
776
+
777
+ if move is not None:
778
+ logger.info(f"Got move {move} from book {book} for game {game.id}")
779
+ return chess.engine.PlayResult(move, None, {"string": "lichess-bot-source:Opening Book"})
780
+
781
+ return no_book_move
782
+
783
+
784
+ def get_online_move(li: lichess.Lichess, board: chess.Board, game: model.Game, online_moves_cfg: Configuration,
785
+ draw_or_resign_cfg: Configuration) -> chess.engine.PlayResult | list[chess.Move]:
786
+ """
787
+ Get a move from an online source.
788
+
789
+ If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
790
+ """
791
+ online_egtb_cfg = online_moves_cfg.online_egtb
792
+ best_move, wdl, comment = get_online_egtb_move(li, board, game, online_egtb_cfg)
793
+ if best_move is not None:
794
+ can_offer_draw = draw_or_resign_cfg.offer_draw_enabled
795
+ offer_draw_for_zero = draw_or_resign_cfg.offer_draw_for_egtb_zero
796
+ offer_draw = can_offer_draw and offer_draw_for_zero and wdl == 0
797
+
798
+ can_resign = draw_or_resign_cfg.resign_enabled
799
+ resign_on_egtb_loss = draw_or_resign_cfg.resign_for_egtb_minus_two
800
+ resign = can_resign and resign_on_egtb_loss and wdl == -2
801
+
802
+ wdl_to_score = {2: 9900, 1: 500, 0: 0, -1: -500, -2: -9900}
803
+ comment["score"] = chess.engine.PovScore(chess.engine.Cp(wdl_to_score[wdl]), board.turn)
804
+ if isinstance(best_move, str):
805
+ return chess.engine.PlayResult(chess.Move.from_uci(best_move),
806
+ None,
807
+ comment,
808
+ draw_offered=offer_draw,
809
+ resigned=resign)
810
+ return [chess.Move.from_uci(move) for move in best_move]
811
+
812
+ max_out_of_book_moves = online_moves_cfg.max_out_of_book_moves
813
+ max_opening_moves = online_moves_cfg.max_depth * 2 - 1
814
+ game_moves = len(board.move_stack)
815
+ if game_moves > max_opening_moves or out_of_online_opening_book_moves[game.id] >= max_out_of_book_moves:
816
+ return chess.engine.PlayResult(None, None)
817
+
818
+ chessdb_cfg = online_moves_cfg.chessdb_book
819
+ lichess_cloud_cfg = online_moves_cfg.lichess_cloud_analysis
820
+ opening_explorer_cfg = online_moves_cfg.lichess_opening_explorer
821
+
822
+ for online_source, cfg in ((get_chessdb_move, chessdb_cfg),
823
+ (get_lichess_cloud_move, lichess_cloud_cfg),
824
+ (get_opening_explorer_move, opening_explorer_cfg)):
825
+ best_move, comment = online_source(li, board, game, cfg)
826
+ if best_move:
827
+ return chess.engine.PlayResult(chess.Move.from_uci(best_move), None, comment)
828
+
829
+ out_of_online_opening_book_moves[game.id] += 1
830
+ used_opening_books = chessdb_cfg.enabled or lichess_cloud_cfg.enabled or opening_explorer_cfg.enabled
831
+ if out_of_online_opening_book_moves[game.id] == max_out_of_book_moves and used_opening_books:
832
+ logger.info(f"Will stop using online opening books for game {game.id}.")
833
+ return chess.engine.PlayResult(None, None)
834
+
835
+
836
+ def get_chessdb_move(li: lichess.Lichess, board: chess.Board, game: model.Game,
837
+ chessdb_cfg: Configuration) -> tuple[str | None, chess.engine.InfoDict]:
838
+ """Get a move from chessdb.cn's opening book."""
839
+ use_chessdb = chessdb_cfg.enabled
840
+ time_left = msec(game.state[wbtime(board)])
841
+ min_time = seconds(chessdb_cfg.min_time)
842
+ max_time = seconds(chessdb_cfg.max_time)
843
+ if not use_chessdb or time_left < min_time or game.clock_initial > max_time or board.uci_variant != "chess":
844
+ return None, {}
845
+
846
+ move = None
847
+ comment: chess.engine.InfoDict = {}
848
+ site = "https://www.chessdb.cn/cdb.php"
849
+ quality = chessdb_cfg.move_quality
850
+ action = {"best": "querypv",
851
+ "good": "querybest",
852
+ "all": "query"}
853
+ with contextlib.suppress(Exception):
854
+ params: dict[str, str | int] = {"action": action[quality], "board": board.fen(), "json": 1}
855
+ data = li.online_book_get(site, params=params)
856
+ if data["status"] == "ok":
857
+ if quality == "best":
858
+ depth = data["depth"]
859
+ if depth >= chessdb_cfg.min_depth:
860
+ score = data["score"]
861
+ move = data["pv"][0]
862
+ comment["score"] = chess.engine.PovScore(chess.engine.Cp(score), board.turn)
863
+ comment["depth"] = data["depth"]
864
+ comment["pv"] = list(map(chess.Move.from_uci, data["pv"]))
865
+ comment["string"] = "lichess-bot-source:ChessDB"
866
+ logger.info(f"Got move {move} from chessdb.cn (depth: {depth}, score: {score}) for game {game.id}")
867
+ else:
868
+ move = data["move"]
869
+ logger.info(f"Got move {move} from chessdb.cn for game {game.id}")
870
+
871
+ return move, comment
872
+
873
+
874
+ def get_lichess_cloud_move(li: lichess.Lichess, board: chess.Board, game: model.Game,
875
+ lichess_cloud_cfg: Configuration) -> tuple[str | None, chess.engine.InfoDict]:
876
+ """Get a move from the lichess's cloud analysis."""
877
+ side = wbtime(board)
878
+ time_left = msec(game.state[side])
879
+ min_time = seconds(lichess_cloud_cfg.min_time)
880
+ max_time = seconds(lichess_cloud_cfg.max_time)
881
+ use_lichess_cloud = lichess_cloud_cfg.enabled
882
+ if not use_lichess_cloud or time_left < min_time or game.clock_initial > max_time:
883
+ return None, {}
884
+
885
+ move = None
886
+ comment: chess.engine.InfoDict = {}
887
+
888
+ quality = lichess_cloud_cfg.move_quality
889
+ multipv = 1 if quality == "best" else 5
890
+ variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant) # `str` is there only for mypy.
891
+
892
+ with contextlib.suppress(Exception):
893
+ data = li.online_book_get("https://lichess.org/api/cloud-eval",
894
+ params={"fen": board.fen(), "multiPv": multipv, "variant": variant},
895
+ authenticated=True)
896
+ if "error" not in data:
897
+ depth = data["depth"]
898
+ knodes = data["knodes"]
899
+ min_depth = lichess_cloud_cfg.min_depth
900
+ min_knodes = lichess_cloud_cfg.min_knodes
901
+ if depth >= min_depth and knodes >= min_knodes:
902
+ if quality == "best":
903
+ pv = data["pvs"][0]
904
+ else:
905
+ best_eval = data["pvs"][0]["cp"]
906
+ pvs = data["pvs"]
907
+ max_difference = lichess_cloud_cfg.max_score_difference
908
+ if side == "wtime":
909
+ pvs = list(filter(lambda pv: pv["cp"] >= best_eval - max_difference, pvs))
910
+ else:
911
+ pvs = list(filter(lambda pv: pv["cp"] <= best_eval + max_difference, pvs))
912
+ pv = random.choice(pvs)
913
+ move = pv["moves"].split()[0]
914
+ score = pv["cp"] if side == "wtime" else -pv["cp"]
915
+ comment["score"] = chess.engine.PovScore(chess.engine.Cp(score), board.turn)
916
+ comment["depth"] = data["depth"]
917
+ comment["nodes"] = data["knodes"] * 1000
918
+ comment["pv"] = list(map(chess.Move.from_uci, pv["moves"].split()))
919
+ comment["string"] = "lichess-bot-source:Lichess Cloud Analysis"
920
+ logger.info(f"Got move {move} from lichess cloud analysis (depth: {depth}, score: {score}, knodes: {knodes})"
921
+ f" for game {game.id}")
922
+
923
+ return move, comment
924
+
925
+
926
+ def get_opening_explorer_move(li: lichess.Lichess, board: chess.Board, game: model.Game,
927
+ opening_explorer_cfg: Configuration
928
+ ) -> tuple[str | None, chess.engine.InfoDict]:
929
+ """Get a move from lichess's opening explorer."""
930
+ side = wbtime(board)
931
+ time_left = msec(game.state[side])
932
+ min_time = seconds(opening_explorer_cfg.min_time)
933
+ max_time = seconds(opening_explorer_cfg.max_time)
934
+ source = opening_explorer_cfg.source
935
+ if (not opening_explorer_cfg.enabled or time_left < min_time or game.clock_initial > max_time or source == "master"
936
+ and board.uci_variant != "chess"):
937
+ return None, {}
938
+
939
+ move = None
940
+ comment: chess.engine.InfoDict = {}
941
+ variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant) # `str` is there only for mypy
942
+ with contextlib.suppress(Exception):
943
+ params: dict[str, str | int]
944
+ if source == "masters":
945
+ params = {"fen": board.fen(), "moves": 100}
946
+ response = li.online_book_get("https://explorer.lichess.ovh/masters", params, authenticated=True)
947
+ comment = {"string": "lichess-bot-source:Lichess Opening Explorer (Masters)"}
948
+ elif source == "player":
949
+ player = opening_explorer_cfg.player_name
950
+ if not player:
951
+ player = game.username
952
+ params = {"player": player, "fen": board.fen(), "moves": 100, "variant": variant,
953
+ "recentGames": 0, "color": "white" if side == "wtime" else "black"}
954
+ response = li.online_book_get("https://explorer.lichess.ovh/player", params, stream=True, authenticated=True)
955
+ comment = {"string": "lichess-bot-source:Lichess Opening Explorer (Player)"}
956
+ else:
957
+ params = {"fen": board.fen(), "moves": 100, "variant": variant, "topGames": 0, "recentGames": 0}
958
+ response = li.online_book_get("https://explorer.lichess.ovh/lichess", params, authenticated=True)
959
+ comment = {"string": "lichess-bot-source:Lichess Opening Explorer (Lichess)"}
960
+ moves = []
961
+ for possible_move in response["moves"]:
962
+ games_played = possible_move["white"] + possible_move["black"] + possible_move["draws"]
963
+ winrate = (possible_move["white"] + possible_move["draws"] * .5) / games_played
964
+ if side == "btime":
965
+ winrate = 1 - winrate
966
+ if games_played >= opening_explorer_cfg.min_games:
967
+ # We add both winrate and games_played to the tuple, so that if 2 moves are tied on the first metric,
968
+ # the second one will be used.
969
+ moves.append((winrate if opening_explorer_cfg.sort == "winrate" else games_played,
970
+ games_played if opening_explorer_cfg.sort == "winrate" else winrate, possible_move["uci"]))
971
+ moves.sort(reverse=True)
972
+ move = moves[0][2]
973
+ logger.info(f"Got move {move} from lichess opening explorer ({opening_explorer_cfg.sort}: {moves[0][0]})"
974
+ f" for game {game.id}")
975
+
976
+ return move, comment
977
+
978
+
979
+ def get_online_egtb_move(li: lichess.Lichess, board: chess.Board, game: model.Game, online_egtb_cfg: Configuration
980
+ ) -> tuple[str | list[str] | None, int, chess.engine.InfoDict]:
981
+ """
982
+ Get a move from an online egtb (either by lichess or chessdb).
983
+
984
+ If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
985
+ """
986
+ use_online_egtb = online_egtb_cfg.enabled
987
+ pieces = chess.popcount(board.occupied)
988
+ source = online_egtb_cfg.source
989
+ minimum_time = seconds(online_egtb_cfg.min_time)
990
+ maximum_time = seconds(online_egtb_cfg.max_time)
991
+ time_left = game.state[wbtime(board)]
992
+ if (not use_online_egtb
993
+ or msec(time_left) < minimum_time
994
+ or game.clock_initial > maximum_time
995
+ or board.uci_variant not in ["chess", "antichess", "atomic"]
996
+ and source == "lichess"
997
+ or board.uci_variant != "chess"
998
+ and source == "chessdb"
999
+ or pieces > online_egtb_cfg.max_pieces
1000
+ or board.castling_rights):
1001
+
1002
+ return None, -3, {}
1003
+
1004
+ quality = online_egtb_cfg.move_quality
1005
+ variant = "standard" if board.uci_variant == "chess" else str(board.uci_variant)
1006
+
1007
+ with contextlib.suppress(Exception):
1008
+ if source == "lichess":
1009
+ return get_lichess_egtb_move(li, game, board, quality, variant)
1010
+ if source == "chessdb":
1011
+ return get_chessdb_egtb_move(li, game, board, quality)
1012
+
1013
+ return None, -3, {}
1014
+
1015
+
1016
+ def get_egtb_move(board: chess.Board, game: model.Game, lichess_bot_tbs: Configuration,
1017
+ draw_or_resign_cfg: Configuration) -> chess.engine.PlayResult | list[chess.Move]:
1018
+ """
1019
+ Get a move from a local egtb.
1020
+
1021
+ If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
1022
+ """
1023
+ best_move, wdl = get_syzygy(board, game, lichess_bot_tbs.syzygy)
1024
+ source = "lichess-bot-source:Syzygy EGTB"
1025
+ if best_move is None:
1026
+ best_move, wdl = get_gaviota(board, game, lichess_bot_tbs.gaviota)
1027
+ source = "lichess-bot-source:Gaviota EGTB"
1028
+ if best_move:
1029
+ can_offer_draw = draw_or_resign_cfg.offer_draw_enabled
1030
+ offer_draw_for_zero = draw_or_resign_cfg.offer_draw_for_egtb_zero
1031
+ offer_draw = bool(can_offer_draw and offer_draw_for_zero and wdl == 0)
1032
+
1033
+ can_resign = draw_or_resign_cfg.resign_enabled
1034
+ resign_on_egtb_loss = draw_or_resign_cfg.resign_for_egtb_minus_two
1035
+ resign = bool(can_resign and resign_on_egtb_loss and wdl == -2)
1036
+ wdl_to_score = {2: 9900, 1: 500, 0: 0, -1: -500, -2: -9900}
1037
+ comment: chess.engine.InfoDict = {"score": chess.engine.PovScore(chess.engine.Cp(wdl_to_score[wdl]), board.turn),
1038
+ "string": source}
1039
+ if isinstance(best_move, chess.Move):
1040
+ return chess.engine.PlayResult(best_move, None, comment, draw_offered=offer_draw, resigned=resign)
1041
+ return best_move
1042
+ return chess.engine.PlayResult(None, None)
1043
+
1044
+
1045
+ def is_op1_position(board: chess.Board) -> bool:
1046
+ """Determine if a given python-chess board represents an 'op1' 8-piece tablebase position."""
1047
+ if len(board.piece_map()) != 8 or board.castling_rights:
1048
+ return False
1049
+
1050
+ white_piece_count = chess.popcount(board.occupied_co[chess.WHITE])
1051
+ black_piece_count = chess.popcount(board.occupied_co[chess.BLACK])
1052
+
1053
+ if white_piece_count <= 2 or black_piece_count <= 2:
1054
+ return False
1055
+
1056
+ white_pawns = board.pieces(chess.PAWN, chess.WHITE)
1057
+ black_pawns = board.pieces(chess.PAWN, chess.BLACK)
1058
+
1059
+ if not white_pawns or not black_pawns:
1060
+ return False
1061
+
1062
+ for wp in white_pawns:
1063
+ for bp in black_pawns:
1064
+ if chess.square_file(wp) == chess.square_file(bp) and chess.square_rank(wp) < chess.square_rank(bp):
1065
+ return True
1066
+
1067
+ return False
1068
+
1069
+
1070
+ def get_lichess_egtb_move(li: lichess.Lichess, game: model.Game, board: chess.Board, quality: str,
1071
+ variant: str) -> tuple[str | list[str] | None, int, chess.engine.InfoDict]:
1072
+ """
1073
+ Get a move from lichess's egtb.
1074
+
1075
+ If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
1076
+ """
1077
+ name_to_wld = {"loss": -2,
1078
+ "maybe-loss": -1,
1079
+ "blessed-loss": -1,
1080
+ "draw": 0,
1081
+ "cursed-win": 1,
1082
+ "maybe-win": 1,
1083
+ "win": 2}
1084
+ pieces = chess.popcount(board.occupied)
1085
+ max_pieces = 8 if board.uci_variant == "chess" else 6
1086
+ metric: Literal["dtz", "dtc"] = "dtz" if pieces < 8 else "dtc"
1087
+ is_op1 = is_op1_position(board)
1088
+ if pieces <= max_pieces and (pieces < 8 or is_op1):
1089
+ data = li.online_book_get(f"https://tablebase.lichess.ovh/{variant}",
1090
+ params={"fen": board.fen()}, authenticated=True)
1091
+ if quality == "best":
1092
+ move = data["moves"][0]["uci"]
1093
+ wdl = name_to_wld[data["moves"][0]["category"]] * -1
1094
+ dtz = data["moves"][0][metric] * -1
1095
+ dtm = data["moves"][0]["dtm"]
1096
+ if dtm:
1097
+ dtm *= -1
1098
+ logger.info(f"Got move {move} from tablebase.lichess.ovh (wdl: {wdl}, {metric}: {dtz}, dtm: {dtm})"
1099
+ f" for game {game.id}")
1100
+ else: # quality == "suggest":
1101
+ best_wdl = name_to_wld[data["moves"][0]["category"]] * -1
1102
+
1103
+ def good_enough(possible_move: LichessEGTBMoveType) -> bool:
1104
+ return name_to_wld[possible_move["category"]] * -1 == best_wdl
1105
+
1106
+ possible_moves = list(filter(good_enough, data["moves"]))
1107
+ if len(possible_moves) > 1:
1108
+ move_list = [move["uci"] for move in possible_moves]
1109
+ wdl = best_wdl
1110
+ logger.info(f"Suggesting moves from tablebase.lichess.ovh (wdl: {wdl}) for game {game.id}")
1111
+ return move_list, wdl, {"string": "lichess-bot-source:Lichess EGTB"}
1112
+ else:
1113
+ best_move = possible_moves[0]
1114
+ move = best_move["uci"]
1115
+ wdl = name_to_wld[best_move["category"]] * -1
1116
+ dtz = best_move[metric] * -1
1117
+ dtm = best_move["dtm"]
1118
+ if dtm:
1119
+ dtm *= -1
1120
+ logger.info(f"Got move {move} from tablebase.lichess.ovh (wdl: {wdl}, {metric}: {dtz}, dtm: {dtm})"
1121
+ f" for game {game.id}")
1122
+
1123
+ return move, wdl, {"string": "lichess-bot-source:Lichess EGTB"}
1124
+ return None, -3, {}
1125
+
1126
+
1127
+ def get_chessdb_egtb_move(li: lichess.Lichess, game: model.Game, board: chess.Board,
1128
+ quality: str) -> tuple[str | list[str] | None, int, chess.engine.InfoDict]:
1129
+ """
1130
+ Get a move from chessdb's egtb.
1131
+
1132
+ If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
1133
+ """
1134
+ def score_to_wdl(score: int) -> int:
1135
+ return piecewise_function([(-20000, "e", -2),
1136
+ (0, "e", -1),
1137
+ (0, "i", 0),
1138
+ (20000, "i", 1)], 2, score)
1139
+
1140
+ def score_to_dtz(score: int) -> int:
1141
+ return piecewise_function([(-20000, "e", -30000 - score),
1142
+ (0, "e", -20000 - score),
1143
+ (0, "i", 0),
1144
+ (20000, "i", 20000 - score)], 30000 - score, score)
1145
+
1146
+ action = "querypv" if quality == "best" else "queryall"
1147
+ data = li.online_book_get("https://www.chessdb.cn/cdb.php",
1148
+ params={"action": action, "board": board.fen(), "json": 1})
1149
+ if data["status"] == "ok":
1150
+ if quality == "best":
1151
+ score = data["score"]
1152
+ move = data["pv"][0]
1153
+ wdl = score_to_wdl(score)
1154
+ dtz = score_to_dtz(score)
1155
+ logger.info(f"Got move {move} from chessdb.cn (wdl: {wdl}, dtz: {dtz}) for game {game.id}")
1156
+ else: # quality == "suggest"
1157
+ best_wdl = score_to_wdl(data["moves"][0]["score"])
1158
+
1159
+ def good_enough(move: ChessDBMoveType) -> bool:
1160
+ return score_to_wdl(move["score"]) == best_wdl
1161
+
1162
+ possible_moves = list(filter(good_enough, cast(list[ChessDBMoveType], data["moves"])))
1163
+ if len(possible_moves) > 1:
1164
+ wdl = score_to_wdl(possible_moves[0]["score"])
1165
+ move_list = [move["uci"] for move in possible_moves]
1166
+ logger.info(f"Suggesting moves from from chessdb.cn (wdl: {wdl}) for game {game.id}")
1167
+ return move_list, wdl, {"string": "lichess-bot-source:ChessDB EGTB"}
1168
+ else:
1169
+ best_move = possible_moves[0]
1170
+ score = best_move["score"]
1171
+ move = best_move["uci"]
1172
+ wdl = score_to_wdl(score)
1173
+ dtz = score_to_dtz(score)
1174
+ logger.info(f"Got move {move} from chessdb.cn (wdl: {wdl}, dtz: {dtz}) for game {game.id}")
1175
+
1176
+ return move, wdl, {"string": "lichess-bot-source:ChessDB EGTB"}
1177
+ return None, -3, {}
1178
+
1179
+
1180
+ def get_syzygy(board: chess.Board, game: model.Game,
1181
+ syzygy_cfg: Configuration) -> tuple[chess.Move | list[chess.Move] | None, int]:
1182
+ """
1183
+ Get a move from local syzygy egtbs.
1184
+
1185
+ If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
1186
+ """
1187
+ if (not syzygy_cfg.enabled
1188
+ or chess.popcount(board.occupied) > syzygy_cfg.max_pieces
1189
+ or board.uci_variant not in ["chess", "antichess", "atomic"]):
1190
+ return None, -3
1191
+
1192
+ move: chess.Move | list[chess.Move]
1193
+ move_quality = syzygy_cfg.move_quality
1194
+
1195
+ with chess.syzygy.open_tablebase(syzygy_cfg.paths[0]) as tablebase:
1196
+ for path in syzygy_cfg.paths[1:]:
1197
+ tablebase.add_directory(path)
1198
+
1199
+ try:
1200
+ moves = score_syzygy_moves(board, dtz_scorer, tablebase)
1201
+
1202
+ best_wdl = max(map(dtz_to_wdl, moves.values()))
1203
+ good_moves = [(move, dtz) for move, dtz in moves.items() if dtz_to_wdl(dtz) == best_wdl]
1204
+ if move_quality == "suggest" and len(good_moves) > 1:
1205
+ move = [chess_move for chess_move, dtz in good_moves]
1206
+ logger.info(f"Suggesting moves from syzygy (wdl: {best_wdl}) for game {game.id}")
1207
+ return move, best_wdl
1208
+ # There can be multiple moves with the same dtz.
1209
+ best_dtz = min(good_moves, key=itemgetter(1))[1]
1210
+ best_moves = [chess_move for chess_move, dtz in good_moves if dtz == best_dtz]
1211
+ move = random.choice(best_moves)
1212
+ logger.info(f"Got move {move.uci()} from syzygy (wdl: {best_wdl}, dtz: {best_dtz}) for game {game.id}")
1213
+ return move, best_wdl
1214
+ except KeyError:
1215
+ # Attempt to only get the WDL score. It returns moves of quality="suggest", even if quality is set to "best".
1216
+ try:
1217
+ moves = score_syzygy_moves(board, lambda tablebase, b: -tablebase.probe_wdl(b), tablebase)
1218
+ best_wdl = int(max(moves.values())) # int is there only for mypy.
1219
+ good_chess_moves = [chess_move for chess_move, wdl in moves.items() if wdl == best_wdl]
1220
+ logger.debug("Found moves using 'move_quality'='suggest'. We didn't find an '.rtbz' file for this endgame."
1221
+ if move_quality == "best" else "")
1222
+ if len(good_chess_moves) > 1:
1223
+ move = good_chess_moves
1224
+ logger.info(f"Suggesting moves from syzygy (wdl: {best_wdl}) for game {game.id}")
1225
+ else:
1226
+ move = good_chess_moves[0]
1227
+ logger.info(f"Got move {move.uci()} from syzygy (wdl: {best_wdl}) for game {game.id}")
1228
+ return move, best_wdl
1229
+ except KeyError:
1230
+ return None, -3
1231
+
1232
+
1233
+ def dtz_scorer(tablebase: chess.syzygy.Tablebase, board: chess.Board) -> int | float:
1234
+ """
1235
+ Score a position based on a syzygy DTZ egtb.
1236
+
1237
+ For a zeroing move (capture or pawn move), a DTZ of +/-0.5 is returned.
1238
+ """
1239
+ dtz: int | float = -tablebase.probe_dtz(board)
1240
+ dtz = dtz if board.halfmove_clock or not dtz else math.copysign(.5, dtz)
1241
+ return dtz + (math.copysign(board.halfmove_clock, dtz) if dtz else 0)
1242
+
1243
+
1244
+ def dtz_to_wdl(dtz: float) -> int:
1245
+ """
1246
+ Convert DTZ scores to syzygy WDL scores.
1247
+
1248
+ A DTZ of +/-100 returns a draw score of +/-1 instead of a win/loss score of +/-2 because
1249
+ a 50-move draw can be forced before checkmate can be forced.
1250
+ """
1251
+ return piecewise_function([(-100, "i", -1), (0, "e", -2), (0, "i", 0), (100, "e", 2)], 1, dtz)
1252
+
1253
+
1254
+ def get_gaviota(board: chess.Board, game: model.Game,
1255
+ gaviota_cfg: Configuration) -> tuple[chess.Move | list[chess.Move] | None, int]:
1256
+ """
1257
+ Get a move from local gaviota egtbs.
1258
+
1259
+ If `move_quality` is `suggest`, then it will return a list of moves for the engine to choose from.
1260
+ """
1261
+ if (not gaviota_cfg.enabled
1262
+ or chess.popcount(board.occupied) > gaviota_cfg.max_pieces
1263
+ or board.uci_variant != "chess"):
1264
+ return None, -3
1265
+
1266
+ move: chess.Move | list[chess.Move]
1267
+ move_quality = gaviota_cfg.move_quality
1268
+
1269
+ # Since gaviota TBs use dtm and not dtz, we have to put a limit where after it the position are considered to have
1270
+ # a syzygy wdl=1/-1, so the positions are draws under the 50 move rule. We use min_dtm_to_consider_as_wdl_1 as a
1271
+ # second limit, because if a position has 5 pieces and dtm=110 it may take 98 half-moves, to go down to 4 pieces and
1272
+ # another 12 to mate, so this position has a syzygy wdl=2/-2. To be safe, the first limit is 100 moves, which
1273
+ # guarantees that all moves have a syzygy wdl=2/-2. Setting min_dtm_to_consider_as_wdl_1 to 100 will disable it
1274
+ # because dtm >= dtz, so if abs(dtm) < 100 => abs(dtz) < 100, so wdl=2/-2.
1275
+ min_dtm_to_consider_as_wdl_1 = gaviota_cfg.min_dtm_to_consider_as_wdl_1
1276
+
1277
+ with chess.gaviota.open_tablebase(gaviota_cfg.paths[0]) as tablebase:
1278
+ for path in gaviota_cfg.paths[1:]:
1279
+ tablebase.add_directory(path)
1280
+
1281
+ try:
1282
+ moves = score_gaviota_moves(board, dtm_scorer, tablebase)
1283
+
1284
+ best_wdl = max(map(dtm_to_gaviota_wdl, moves.values()))
1285
+ good_moves = [(move, dtm) for move, dtm in moves.items() if dtm_to_gaviota_wdl(dtm) == best_wdl]
1286
+ best_dtm = min(good_moves, key=itemgetter(1))[1]
1287
+
1288
+ pseudo_wdl = dtm_to_wdl(best_dtm, min_dtm_to_consider_as_wdl_1)
1289
+ if move_quality == "suggest":
1290
+ best_moves = good_enough_gaviota_moves(good_moves, best_dtm, min_dtm_to_consider_as_wdl_1)
1291
+ if len(best_moves) > 1:
1292
+ move = [chess_move for chess_move, dtm in best_moves]
1293
+ logger.info(f"Suggesting moves from gaviota (pseudo wdl: {pseudo_wdl}) for game {game.id}")
1294
+ else:
1295
+ move, dtm = best_moves[0]
1296
+ logger.info(f"Got move {move.uci()} from gaviota (pseudo wdl: {pseudo_wdl}, dtm: {dtm})"
1297
+ f" for game {game.id}")
1298
+ else:
1299
+ # There can be multiple moves with the same dtm.
1300
+ best_moves = [(move, dtm) for move, dtm in good_moves if dtm == best_dtm]
1301
+ move, dtm = random.choice(best_moves)
1302
+ logger.info(f"Got move {move.uci()} from gaviota (pseudo wdl: {pseudo_wdl}, dtm: {dtm}) for game {game.id}")
1303
+ return move, pseudo_wdl
1304
+ except KeyError:
1305
+ return None, -3
1306
+
1307
+
1308
+ def dtm_scorer(tablebase: chess.gaviota.NativeTablebase | chess.gaviota.PythonTablebase, board: chess.Board) -> int:
1309
+ """Score a position based on a gaviota DTM egtb."""
1310
+ dtm = -tablebase.probe_dtm(board)
1311
+ return dtm + int(math.copysign(board.halfmove_clock, dtm) if dtm else 0)
1312
+
1313
+
1314
+ def dtm_to_gaviota_wdl(dtm: int) -> int:
1315
+ """Convert DTM scores to gaviota WDL scores."""
1316
+ return piecewise_function([(-1, "i", -1), (0, "i", 0)], 1, dtm)
1317
+
1318
+
1319
+ def dtm_to_wdl(dtm: int, min_dtm_to_consider_as_wdl_1: int) -> int:
1320
+ """Convert DTM scores to syzygy WDL scores."""
1321
+ # We use 100 and not min_dtm_to_consider_as_wdl_1, because we want to play it safe and not resign in a
1322
+ # position where dtz=-102 (only if resign_for_egtb_minus_two is enabled).
1323
+ return piecewise_function([(-100, "i", -1), (-1, "i", -2), (0, "i", 0), (min_dtm_to_consider_as_wdl_1, "e", 2)], 1, dtm)
1324
+
1325
+
1326
+ def good_enough_gaviota_moves(good_moves: list[tuple[chess.Move, int]], best_dtm: int,
1327
+ min_dtm_to_consider_as_wdl_1: int) -> list[tuple[chess.Move, int]]:
1328
+ """
1329
+ Get the moves that are good enough to consider.
1330
+
1331
+ :param good_moves: All the moves to choose from.
1332
+ :param best_dtm: The best DTM score of a move.
1333
+ :param min_dtm_to_consider_as_wdl_1: The minimum DTM score to consider as WDL=1.
1334
+ :return: A list of the moves that are good enough to consider.
1335
+ """
1336
+ if best_dtm < 100:
1337
+ # If a move had wdl=2 and dtz=98, but halfmove_clock is 4 then the real wdl=1 and dtz=102, so we
1338
+ # want to avoid these positions, if there is a move where even when we add the halfmove_clock the
1339
+ # dtz is still <100.
1340
+ return [(move, dtm) for move, dtm in good_moves if dtm < 100]
1341
+ if best_dtm < min_dtm_to_consider_as_wdl_1:
1342
+ # If a move had wdl=2 and dtz=98, but halfmove_clock is 4 then the real wdl=1 and dtz=102, so we
1343
+ # want to avoid these positions, if there is a move where even when we add the halfmove_clock the
1344
+ # dtz is still <100.
1345
+ return [(move, dtm) for move, dtm in good_moves if dtm < min_dtm_to_consider_as_wdl_1]
1346
+ if best_dtm <= -min_dtm_to_consider_as_wdl_1:
1347
+ # If a move had wdl=-2 and dtz=-98, but halfmove_clock is 4 then the real wdl=-1 and dtz=-102, so we
1348
+ # want to only choose between the moves where the real wdl=-1.
1349
+ return [(move, dtm) for move, dtm in good_moves if dtm <= -min_dtm_to_consider_as_wdl_1]
1350
+ if best_dtm <= -100:
1351
+ # If a move had wdl=-2 and dtz=-98, but halfmove_clock is 4 then the real wdl=-1 and dtz=-102, so we
1352
+ # want to only choose between the moves where the real wdl=-1.
1353
+ return [(move, dtm) for move, dtm in good_moves if dtm <= -100]
1354
+ return good_moves
1355
+
1356
+
1357
+ def piecewise_function(range_definitions: list[tuple[float, Literal["e", "i"], int]], last_value: int,
1358
+ position: float) -> int:
1359
+ """
1360
+ Return a value according to a position argument.
1361
+
1362
+ This function is meant to replace if-elif-else blocks that turn ranges into discrete values.
1363
+ Each tuple in the list has three parts: an upper limit, and inclusive/exclusive indicator, and
1364
+ a value. For example,
1365
+ `piecewise_function([(-20000, "e", 2), (0, "e" -1), (0, "i", 0), (20000, "i", 1)], 2, score)` is equivalent to:
1366
+
1367
+ if score < -20000:
1368
+ return -2
1369
+ elif score < 0:
1370
+ return -1
1371
+ elif score <= 0:
1372
+ return 0
1373
+ elif score <= 20000:
1374
+ return 1
1375
+ else:
1376
+ return 2
1377
+
1378
+ Arguments:
1379
+ range_definitions:
1380
+ A list of tuples with the first element being the inclusive right border of region and the second
1381
+ element being the associated value. An element of this list (a, "i", b) corresponds to an
1382
+ inclusive limit and is equivalent to
1383
+ if x <= a:
1384
+ return b
1385
+ where x is the value of the position argument. An element of the form (a, "e", b) corresponds to
1386
+ an exclusive limit and is equivalent to
1387
+ if x < a:
1388
+ return b
1389
+ For correct operation, this argument should be sorted by the first element. If two ranges have the
1390
+ same border, one with "e" and the other with "i", the "e" element should be first.
1391
+ last_value:
1392
+ If the position argument does not fall in any of the ranges in the range_definition argument,
1393
+ return this value.
1394
+ position:
1395
+ The value that will be compared to the first element of the range_definitions tuples.
1396
+
1397
+ """
1398
+ for border, inc_exc, value in range_definitions:
1399
+ if position < border or (inc_exc == "i" and position == border):
1400
+ return value
1401
+ return last_value
1402
+
1403
+
1404
+ def score_syzygy_moves(board: chess.Board,
1405
+ scorer: Callable[[chess.syzygy.Tablebase, chess.Board], int] |
1406
+ Callable[[chess.syzygy.Tablebase, chess.Board], int | float],
1407
+ tablebase: chess.syzygy.Tablebase) -> dict[chess.Move, int | float]:
1408
+ """Score all the moves using syzygy egtbs."""
1409
+ moves = {}
1410
+ for move in board.legal_moves:
1411
+ board.push(move)
1412
+ moves[move] = scorer(tablebase, board)
1413
+ board.pop()
1414
+ return moves
1415
+
1416
+
1417
+ def score_gaviota_moves(board: chess.Board,
1418
+ scorer: Callable[[chess.gaviota.NativeTablebase | chess.gaviota.PythonTablebase, chess.Board], int],
1419
+ tablebase: chess.gaviota.NativeTablebase | chess.gaviota.PythonTablebase
1420
+ ) -> dict[chess.Move, int]:
1421
+ """Score all the moves using gaviota egtbs."""
1422
+ moves = {}
1423
+ for move in board.legal_moves:
1424
+ board.push(move)
1425
+ moves[move] = scorer(tablebase, board)
1426
+ board.pop()
1427
+ return moves
lichess-bot-master/lib/lichess.py ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 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
+ def __init__(self, message: str, timeout: datetime.timedelta) -> None:
65
+ """Create a rate-limited error with the time left until the rate limit expires."""
66
+ super().__init__(message)
67
+ self.timeout = timeout
68
+
69
+
70
+ def is_new_rate_limit(response: requests.models.Response) -> bool:
71
+ """Check if the status code is 429, which means that we are rate limited."""
72
+ return response.status_code == 429
73
+
74
+ def is_daily_game_rate_limit(response: requests.models.Response, rate_limit_status_code: int) -> bool:
75
+ """Check if response to challenge is a rate limit, either of the bot or the opponent."""
76
+ if response.status_code != rate_limit_status_code:
77
+ return False
78
+
79
+ try:
80
+ body = response.json()
81
+ return "error" in body and body.get("ratelimit", {}).get("key", "") == "bot.vsBot.day"
82
+ except requests.exceptions.JSONDecodeError:
83
+ return False
84
+
85
+
86
+ def is_opponent_rate_limit(response: requests.models.Response) -> bool:
87
+ """Check if response to a challenge is 400, which means opponent is rate limited."""
88
+ return is_daily_game_rate_limit(response, 400)
89
+
90
+
91
+ def is_bot_rate_limit(response: requests.models.Response) -> bool:
92
+ """Check if response to a challenge is 429, which means the bot is rate limited."""
93
+ return is_daily_game_rate_limit(response, 429)
94
+
95
+
96
+ def get_challenge_timeout(challenge_response: ChallengeType) -> datetime.timedelta | None:
97
+ """Return the timeout in a challenge response if the bot or the opponent cannot play another game."""
98
+ rate_limit = challenge_response.get("ratelimit", {})
99
+ key = rate_limit.get("key", "")
100
+ if key == "bot.vsBot.day":
101
+ return seconds(float(rate_limit["seconds"]))
102
+ return None
103
+
104
+
105
+ def is_final(exception: Exception) -> bool:
106
+ """If `is_final` returns True then we won't retry."""
107
+ return (isinstance(exception, HTTPError) and exception.response is not None and exception.response.status_code < 500
108
+ or stop.force_quit)
109
+
110
+
111
+ def backoff_handler(details: BackoffDetails) -> None:
112
+ """Log exceptions inside functions with the backoff decorator."""
113
+ args = details["args"]
114
+ kwargs = details["kwargs"]
115
+ if "token_test" in args:
116
+ kwargs["data"] = "<token redacted>"
117
+ logger.debug("Backing off {wait:0.1f} seconds after {tries} tries "
118
+ "calling function {target} with args {args} and kwargs {kwargs}".format(**details))
119
+ logger.debug(f"Exception: {traceback.format_exc()}")
120
+
121
+
122
+ # Docs: https://lichess.org/api.
123
+ class Lichess:
124
+ """Communication with lichess.org (and chessdb.cn for getting moves)."""
125
+
126
+ def __init__(self, token: str, url: str, version: str, logging_level: int, max_retries: int) -> None:
127
+ """
128
+ Communication with lichess.org (and chessdb.cn for getting moves).
129
+
130
+ :param token: The bot's token.
131
+ :param url: The base url (lichess.org).
132
+ :param version: The lichess-bot version running.
133
+ :param logging_level: The logging level (logging.INFO or logging.DEBUG).
134
+ :param max_retries: The maximum amount of retries for online moves (e.g. chessdb's opening book).
135
+ """
136
+ self.version = version
137
+ self.header = {
138
+ "Authorization": f"Bearer {token}"
139
+ }
140
+ self.baseUrl = url
141
+ self.session = requests.Session()
142
+ self.session.headers.update(self.header)
143
+ self.other_session = requests.Session()
144
+ self.set_user_agent("?")
145
+ self.logging_level = logging_level
146
+ self.max_retries = max_retries
147
+ self.rate_limit_timers: defaultdict[str, Timer] = defaultdict(Timer)
148
+
149
+ # Confirm that the OAuth token has the proper permission to play on lichess
150
+ token_response = cast(TOKEN_TESTS_TYPE, self.api_post("token_test", data=token))
151
+ token_info = token_response.get(token)
152
+
153
+ if not token_info:
154
+ raise RuntimeError("There was an error in retrieving information about the bot's token. "
155
+ "Please check that it was copied correctly into your configuration file "
156
+ "and try again.")
157
+
158
+ scopes = token_info.get("scopes", "")
159
+ if "bot:play" not in scopes.split(","):
160
+ raise RuntimeError("Please use an API access token for your bot that "
161
+ 'has the scope "Play games with the bot API (bot:play)". '
162
+ f"The current token has: {scopes}.")
163
+
164
+ @backoff.on_exception(backoff.constant,
165
+ (RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout),
166
+ max_time=60,
167
+ interval=0.1,
168
+ giveup=is_final,
169
+ on_backoff=backoff_handler,
170
+ backoff_log_level=logging.DEBUG,
171
+ giveup_log_level=logging.DEBUG)
172
+ def api_get(self, endpoint_name: str, *template_args: str,
173
+ params: dict[str, str] | None = None,
174
+ stream: bool = False, timeout: int = 2) -> requests.Response:
175
+ """
176
+ Send a GET to lichess.org.
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
+ :param stream: Whether the data returned from lichess.org should be streamed.
182
+ :param timeout: The amount of time in seconds to wait for a response.
183
+ :return: lichess.org's response.
184
+ """
185
+ logging.getLogger("backoff").setLevel(self.logging_level)
186
+ path_template = self.get_path_template(endpoint_name)
187
+ url = urljoin(self.baseUrl, path_template.format(*template_args))
188
+ response = self.session.get(url, params=params, timeout=timeout, stream=stream)
189
+
190
+ if is_new_rate_limit(response):
191
+ delay = seconds(1 if endpoint_name == "move" else 60)
192
+ self.set_rate_limit_delay(path_template, delay)
193
+
194
+ response.raise_for_status()
195
+ response.encoding = "utf-8"
196
+ return response
197
+
198
+ def api_get_json(self, endpoint_name: str, *template_args: str,
199
+ params: dict[str, str] | None = None
200
+ ) -> PublicDataType | UserProfileType | dict[str, list[GameType]]:
201
+ """
202
+ Send a GET to the lichess.org endpoints that return a JSON.
203
+
204
+ :param endpoint_name: The name of the endpoint.
205
+ :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
206
+ :param params: Parameters sent to lichess.org.
207
+ :return: lichess.org's response in a dict.
208
+ """
209
+ response = self.api_get(endpoint_name, *template_args, params=params)
210
+ json_response: PublicDataType | UserProfileType | dict[str, list[GameType]] = response.json()
211
+ return json_response
212
+
213
+ def api_get_list(self, endpoint_name: str, *template_args: str,
214
+ params: dict[str, str] | None = None) -> list[UserProfileType]:
215
+ """
216
+ Send a GET to the lichess.org endpoints that return a list containing JSON.
217
+
218
+ :param endpoint_name: The name of the endpoint.
219
+ :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
220
+ :param params: Parameters sent to lichess.org.
221
+ :return: lichess.org's response in a list of dicts.
222
+ """
223
+ response = self.api_get(endpoint_name, *template_args, params=params)
224
+ json_response: list[UserProfileType] = response.json()
225
+ return json_response
226
+
227
+ def api_get_raw(self, endpoint_name: str, *template_args: str,
228
+ params: dict[str, str] | None = None) -> str:
229
+ """
230
+ Send a GET to lichess.org that returns plain text (UTF-8).
231
+
232
+ :param endpoint_name: The name of the endpoint.
233
+ :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
234
+ :param params: Parameters sent to lichess.org.
235
+ :return: The text of lichess.org's response.
236
+ """
237
+ response = self.api_get(endpoint_name, *template_args, params=params)
238
+ return response.text
239
+
240
+ @backoff.on_exception(backoff.constant,
241
+ (RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout),
242
+ max_time=60,
243
+ interval=0.1,
244
+ giveup=is_final,
245
+ on_backoff=backoff_handler,
246
+ backoff_log_level=logging.DEBUG,
247
+ giveup_log_level=logging.DEBUG)
248
+ def api_post(self,
249
+ endpoint_name: str,
250
+ *template_args: str,
251
+ data: str | dict[str, str] | None = None,
252
+ headers: dict[str, str] | None = None,
253
+ params: dict[str, str] | None = None,
254
+ payload: REQUESTS_PAYLOAD_TYPE | None = None,
255
+ raise_for_status: bool = True) -> ChallengeType | TOKEN_TESTS_TYPE | None:
256
+ """
257
+ Send a POST to lichess.org.
258
+
259
+ :param endpoint_name: The name of the endpoint.
260
+ :param template_args: The values that go in the url (e.g. the challenge id if `endpoint_name` is `accept`).
261
+ :param data: Data sent to lichess.org.
262
+ :param headers: The headers for the request.
263
+ :param params: Parameters sent to lichess.org.
264
+ :param payload: Payload sent to lichess.org.
265
+ :param raise_for_status: Whether to raise an exception if the response contains an error code.
266
+ :return: lichess.org's response in a dict.
267
+ """
268
+ logging.getLogger("backoff").setLevel(self.logging_level)
269
+ path_template = self.get_path_template(endpoint_name)
270
+ url = urljoin(self.baseUrl, path_template.format(*template_args))
271
+ response = self.session.post(url, data=data, headers=headers, params=params, json=payload, timeout=2)
272
+
273
+ if endpoint_name == "challenge":
274
+ return self.handle_challenge(response)
275
+
276
+ if is_new_rate_limit(response):
277
+ self.set_rate_limit_delay(path_template, seconds(60))
278
+
279
+ if raise_for_status:
280
+ response.raise_for_status()
281
+
282
+ json_response: ChallengeType | TOKEN_TESTS_TYPE | None = response.json()
283
+ return json_response
284
+
285
+ def get_path_template(self, endpoint_name: str) -> str:
286
+ """
287
+ Get the path template given the endpoint name. Will raise an exception if the path template is rate limited.
288
+
289
+ :param endpoint_name: The name of the endpoint.
290
+ :return: The path template.
291
+ """
292
+ path_template = ENDPOINTS[endpoint_name]
293
+ if self.is_rate_limited(path_template):
294
+ time_left = self.rate_limit_time_left(path_template)
295
+ raise RateLimitedError(
296
+ f"{path_template} is rate-limited. Will retry in {sec_str(time_left)} seconds.", time_left)
297
+ return path_template
298
+
299
+ def handle_challenge(self, response: requests.models.Response) -> ChallengeType:
300
+ """Handle the response to a challenge and, if necessary, the daily game timeout."""
301
+ bot_is_rate_limited = is_bot_rate_limit(response)
302
+ opponent_is_rate_limited = is_opponent_rate_limit(response)
303
+ challenge_response: ChallengeType = response.json()
304
+ if bot_is_rate_limited or opponent_is_rate_limited:
305
+ delay = cast(datetime.timedelta, get_challenge_timeout(challenge_response))
306
+ if bot_is_rate_limited:
307
+ self.set_rate_limit_delay(ENDPOINTS["challenge"], delay)
308
+ challenge_response["bot_is_rate_limited"] = bot_is_rate_limited
309
+ challenge_response["opponent_is_rate_limited"] = opponent_is_rate_limited
310
+ challenge_response["rate_limit_timeout"] = delay
311
+
312
+ return challenge_response
313
+
314
+ def set_rate_limit_delay(self, path_template: str, delay_time: datetime.timedelta) -> None:
315
+ """
316
+ Set a delay to a path template if it was rate limited.
317
+
318
+ :param path_template: The path template.
319
+ :param delay_time: How long we won't call this endpoint.
320
+ """
321
+ logger.warning(f"Endpoint {path_template} is rate limited. Waiting {sec_str(delay_time)} seconds until next request.")
322
+ self.rate_limit_timers[path_template] = Timer(delay_time)
323
+
324
+ def is_rate_limited(self, path_template: str) -> bool:
325
+ """Check if a path template is rate limited."""
326
+ return not self.rate_limit_timers[path_template].is_expired()
327
+
328
+ def rate_limit_time_left(self, path_template: str) -> datetime.timedelta:
329
+ """How much time is left until we can use the path template normally."""
330
+ return self.rate_limit_timers[path_template].time_until_expiration()
331
+
332
+ def upgrade_to_bot_account(self) -> None:
333
+ """Upgrade the account to a BOT account."""
334
+ self.api_post("upgrade")
335
+
336
+ def make_move(self, game_id: str, move: chess.engine.PlayResult) -> None:
337
+ """
338
+ Make a move.
339
+
340
+ :param game_id: The id of the game.
341
+ :param move: The move to make.
342
+ """
343
+ self.api_post("move", game_id, str(move.move),
344
+ params={"offeringDraw": str(move.draw_offered).lower()})
345
+
346
+ def accept_takeback(self, game_id: str, accept: bool) -> bool:
347
+ """Answer an opponent's move takeback request."""
348
+ try:
349
+ self.api_post("takeback", game_id, "yes" if accept else "no")
350
+ if accept:
351
+ logger.info("Opponent took back previous move.")
352
+ else:
353
+ logger.info("Refused opponent's take back request.")
354
+ return accept
355
+ except Exception:
356
+ return False
357
+
358
+ def chat(self, game_id: str, room: str, text: str) -> None:
359
+ """
360
+ Send a message to the chat.
361
+
362
+ :param game_id: The id of the game.
363
+ :param room: The room (either chat or spectator room).
364
+ :param text: The text to send.
365
+ """
366
+ if len(text) > MAX_CHAT_MESSAGE_LEN:
367
+ logger.warning(f"This chat message is {len(text)} characters, which is longer "
368
+ f"than the maximum of {MAX_CHAT_MESSAGE_LEN}. It will not be sent.")
369
+ logger.warning(f"Message: {text}")
370
+
371
+ data = {"room": room, "text": text}
372
+ self.api_post("chat", game_id, data=data)
373
+
374
+ def abort(self, game_id: str) -> None:
375
+ """Aborts a game."""
376
+ self.api_post("abort", game_id)
377
+
378
+ def get_event_stream(self) -> requests.models.Response:
379
+ """Get a stream of the events (e.g. challenge, gameStart)."""
380
+ return self.api_get("stream_event", stream=True, timeout=15)
381
+
382
+ def get_game_stream(self, game_id: str) -> requests.models.Response:
383
+ """Get stream of the in-game events (e.g. moves by the opponent)."""
384
+ return self.api_get("stream", game_id, stream=True, timeout=15)
385
+
386
+ def accept_challenge(self, challenge_id: str) -> None:
387
+ """Accept a challenge."""
388
+ self.api_post("accept", challenge_id)
389
+
390
+ def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None:
391
+ """Decline a challenge."""
392
+ with contextlib.suppress(Exception):
393
+ self.api_post("decline", challenge_id,
394
+ data=f"reason={reason}",
395
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
396
+ raise_for_status=False)
397
+
398
+ def get_profile(self) -> UserProfileType:
399
+ """Get the bot's profile (e.g. username)."""
400
+ profile = cast(UserProfileType, self.api_get_json("profile"))
401
+ self.set_user_agent(profile["username"])
402
+ return profile
403
+
404
+ def get_ongoing_games(self) -> list[GameType] | None:
405
+ """
406
+ Get the bot's ongoing games.
407
+
408
+ If an error occurs when retrieving the games, None is returned.
409
+ """
410
+ with contextlib.suppress(Exception):
411
+ response = cast(dict[str, list[GameType]], self.api_get_json("playing"))
412
+ return response["nowPlaying"]
413
+ return None
414
+
415
+ def resign(self, game_id: str) -> None:
416
+ """Resign a game."""
417
+ self.api_post("resign", game_id)
418
+
419
+ def set_user_agent(self, username: str) -> None:
420
+ """Set the user agent for communication with lichess.org."""
421
+ self.header.update({"User-Agent": f"lichess-bot/{self.version} user:{username}"})
422
+ self.session.headers.update(self.header)
423
+
424
+ def get_game_pgn(self, game_id: str) -> str:
425
+ """Get the PGN (Portable Game Notation) record of a game."""
426
+ try:
427
+ return self.api_get_raw("export", game_id)
428
+ except Exception:
429
+ return ""
430
+
431
+ def get_online_bots(self) -> list[UserProfileType]:
432
+ """Get a list of bots that are online."""
433
+ try:
434
+ online_bots_str = self.api_get_raw("online_bots")
435
+ online_bots = list(filter(bool, online_bots_str.split("\n")))
436
+ return list(map(json.loads, online_bots))
437
+ except Exception:
438
+ return []
439
+
440
+ def challenge(self, username: str, payload: REQUESTS_PAYLOAD_TYPE) -> ChallengeType:
441
+ """Create a challenge."""
442
+ return cast(ChallengeType,
443
+ self.api_post("challenge", username, payload=payload, raise_for_status=False))
444
+
445
+ def cancel(self, challenge_id: str) -> None:
446
+ """Cancel a challenge."""
447
+ self.api_post("cancel", challenge_id, raise_for_status=False)
448
+
449
+ def online_book_get(self, path: str, params: dict[str, str | int] | None = None, *,
450
+ stream: bool = False, authenticated: bool = False) -> OnlineType:
451
+ """Get an external move from online sources (chessdb or lichess.org)."""
452
+ @backoff.on_exception(backoff.constant,
453
+ (RemoteDisconnected, RequestsConnectionError, HTTPError, ReadTimeout),
454
+ max_time=60,
455
+ max_tries=self.max_retries,
456
+ interval=0.1,
457
+ giveup=is_final,
458
+ on_backoff=backoff_handler,
459
+ backoff_log_level=logging.DEBUG,
460
+ giveup_log_level=logging.DEBUG)
461
+ def online_book_get() -> OnlineType:
462
+ session = self.session if authenticated else self.other_session # Choose session based on authentication need
463
+ json_response: OnlineType = session.get(path, timeout=2, params=params, stream=stream).json()
464
+ return json_response
465
+ return online_book_get()
466
+
467
+ def is_online(self, user_id: str) -> bool:
468
+ """Check if lichess.org thinks the bot is online or not."""
469
+ user = self.api_get_list("status", params={"ids": user_id})
470
+ return bool(user and user[0].get("online"))
471
+
472
+ def get_public_data(self, user_name: str) -> PublicDataType:
473
+ """Get the public data of a bot."""
474
+ return cast(PublicDataType, self.api_get_json("public_data", user_name))
lichess-bot-master/lib/lichess_bot.py ADDED
@@ -0,0 +1,1277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The main module that controls lichess-bot."""
2
+ import argparse
3
+ import chess
4
+ import chess.pgn
5
+ from chess.variant import find_variant
6
+ from lib import engine_wrapper, model, lichess, matchmaking
7
+ import json
8
+ import logging
9
+ import logging.handlers
10
+ import multiprocessing
11
+ import signal
12
+ import time
13
+ import datetime
14
+ import backoff
15
+ import os
16
+ import io
17
+ import copy
18
+ import math
19
+ import sys
20
+ import yaml
21
+ import traceback
22
+ import itertools
23
+ import glob
24
+ import platform
25
+ import importlib.metadata
26
+ import contextlib
27
+ from lib.blocklist import OnlineBlocklist
28
+ from lib.config import load_config, Configuration, log_config
29
+ from lib.conversation import Conversation, ChatLine
30
+ from lib.timer import Timer, seconds, msec, hours, to_seconds
31
+ from lib.lichess import stop
32
+ from lib.lichess_types import (UserProfileType, EventType, GameType, GameEventType, CONTROL_QUEUE_TYPE,
33
+ CORRESPONDENCE_QUEUE_TYPE, LOGGING_QUEUE_TYPE, PGN_QUEUE_TYPE)
34
+ from requests.exceptions import (ChunkedEncodingError, ConnectionError as RequestsConnectionError, HTTPError, ReadTimeout,
35
+ RequestException)
36
+ from rich.logging import RichHandler
37
+ from collections import defaultdict
38
+ from collections.abc import Iterator, MutableSequence
39
+ from http.client import RemoteDisconnected
40
+ from queue import Empty
41
+ from multiprocessing.pool import Pool
42
+ from collections import Counter
43
+ from typing import TypedDict, cast, TypeAlias
44
+ from types import FrameType
45
+ MULTIPROCESSING_LIST_TYPE: TypeAlias = MutableSequence[model.Challenge]
46
+ POOL_TYPE: TypeAlias = Pool
47
+
48
+
49
+ class PlayGameArgsType(TypedDict, total=False):
50
+ """Type hint for `play_game_args`."""
51
+
52
+ li: lichess.Lichess
53
+ control_queue: CONTROL_QUEUE_TYPE
54
+ user_profile: UserProfileType
55
+ config: Configuration
56
+ challenge_queue: MULTIPROCESSING_LIST_TYPE
57
+ correspondence_queue: CORRESPONDENCE_QUEUE_TYPE
58
+ logging_queue: LOGGING_QUEUE_TYPE
59
+ pgn_queue: PGN_QUEUE_TYPE
60
+ game_id: str
61
+
62
+
63
+ class VersioningType(TypedDict):
64
+ """Type hint for the versioning information from lib/versioning.yml."""
65
+
66
+ lichess_bot_version: str
67
+ minimum_python_version: str
68
+ deprecated_python_version: str
69
+ deprecation_date: datetime.date
70
+
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+ with open(os.path.join(os.path.dirname(__file__), "versioning.yml")) as version_file:
75
+ versioning_info: VersioningType = yaml.safe_load(version_file)
76
+
77
+ __version__ = versioning_info["lichess_bot_version"]
78
+
79
+
80
+ def should_restart() -> bool:
81
+ """Decide whether to restart lichess-bot when exiting main program."""
82
+ return stop.restart
83
+
84
+
85
+ def disable_restart() -> None:
86
+ """Disable restarting lichess-bot when errors occur. Used during testing."""
87
+ stop.restart = False
88
+
89
+
90
+ def signal_handler(signal: int, frame: FrameType | None) -> None: # noqa: ARG001
91
+ """Terminate lichess-bot."""
92
+ if not stop.terminated:
93
+ logger.debug("Received SIGINT. Terminating client.")
94
+ stop.terminated = True
95
+ else:
96
+ logger.debug("Received second SIGINT. Quitting now.")
97
+ stop.force_quit = True
98
+
99
+
100
+ signal.signal(signal.SIGINT, signal_handler)
101
+
102
+
103
+ def upgrade_account(li: lichess.Lichess) -> bool:
104
+ """Upgrade the account to a BOT account."""
105
+ try:
106
+ li.upgrade_to_bot_account()
107
+ except HTTPError:
108
+ logger.exception("Failed to upgrade to Bot Account.")
109
+ return False
110
+
111
+ logger.info("Successfully upgraded to Bot Account!")
112
+ return True
113
+
114
+
115
+ def watch_control_stream(control_queue: CONTROL_QUEUE_TYPE, li: lichess.Lichess) -> None:
116
+ """Put the events in a queue."""
117
+ while not stop.terminated:
118
+ try:
119
+ with li.get_event_stream() as response:
120
+ lines = response.iter_lines()
121
+ for line in lines:
122
+ if line:
123
+ event = json.loads(line.decode("utf-8"))
124
+ control_queue.put_nowait(event)
125
+ else:
126
+ control_queue.put_nowait({"type": "ping"})
127
+ except Exception:
128
+ logger.warning(f"Control stream error, reconnecting:\n{traceback.format_exc()}")
129
+ time.sleep(1)
130
+
131
+ control_queue.put_nowait({"type": "terminated"})
132
+
133
+
134
+ def do_correspondence_ping(control_queue: CONTROL_QUEUE_TYPE, period: datetime.timedelta) -> None:
135
+ """
136
+ Tell the engine to check the correspondence games.
137
+
138
+ :param control_queue: Queue to put events in.
139
+ :param period: How many seconds to wait before sending a correspondence ping.
140
+ """
141
+ while not stop.terminated:
142
+ time.sleep(to_seconds(period))
143
+ control_queue.put_nowait({"type": "correspondence_ping"})
144
+
145
+
146
+ def write_pgn_records(pgn_queue: PGN_QUEUE_TYPE, config: Configuration, username: str) -> None:
147
+ """Write PGN records to files as games finish."""
148
+ while True:
149
+ mark_task_done = False
150
+ try:
151
+ event = pgn_queue.get()
152
+ mark_task_done = True
153
+ if event:
154
+ save_pgn_record(event, config, username)
155
+ except InterruptedError:
156
+ pass
157
+ except Exception:
158
+ logger.exception("Could not write PGN to file")
159
+
160
+ if mark_task_done:
161
+ pgn_queue.task_done()
162
+
163
+
164
+ def logging_configurer(level: int, filename: str | None, disable_auto_logs: bool) -> None:
165
+ """
166
+ Configure the logger.
167
+
168
+ :param level: The logging level. Either `logging.INFO` or `logging.DEBUG`.
169
+ :param filename: The filename to write the logs to. If it is `None` then the logs aren't written to a file.
170
+ :param disable_auto_logs: Whether to disable automatic logging.
171
+ """
172
+ console_handler = RichHandler()
173
+ console_formatter = logging.Formatter("%(message)s")
174
+ console_handler.setFormatter(console_formatter)
175
+ console_handler.setLevel(level)
176
+ all_handlers: list[logging.Handler] = [console_handler]
177
+
178
+ if filename:
179
+ file_handler = logging.FileHandler(filename, delay=True, encoding="utf-8")
180
+ FORMAT = "%(asctime)s %(name)s (%(filename)s:%(lineno)d) %(levelname)s %(message)s"
181
+ file_formatter = logging.Formatter(FORMAT)
182
+ file_handler.setFormatter(file_formatter)
183
+ file_handler.setLevel(level)
184
+ all_handlers.append(file_handler)
185
+
186
+ if not disable_auto_logs:
187
+ os.makedirs(auto_log_directory, exist_ok=True)
188
+
189
+ # Set up automatic logging.
190
+ auto_log_filename = os.path.join(auto_log_directory, "lichess-bot.log")
191
+ auto_file_handler = logging.handlers.TimedRotatingFileHandler(auto_log_filename,
192
+ delay=True,
193
+ encoding="utf-8",
194
+ when="midnight",
195
+ backupCount=7)
196
+ auto_file_handler.setLevel(logging.DEBUG)
197
+
198
+ FORMAT = "%(asctime)s %(name)s (%(filename)s:%(lineno)d) %(levelname)s %(message)s"
199
+ file_formatter = logging.Formatter(FORMAT)
200
+ auto_file_handler.setFormatter(file_formatter)
201
+ all_handlers.append(auto_file_handler)
202
+
203
+ logging.basicConfig(level=logging.DEBUG,
204
+ handlers=all_handlers,
205
+ force=True)
206
+
207
+
208
+ def logging_listener_proc(queue: LOGGING_QUEUE_TYPE, level: int, log_filename: str | None,
209
+ disable_auto_logging: bool) -> None:
210
+ """
211
+ Handle events from the logging queue.
212
+
213
+ This allows the logs from inside a thread to be printed.
214
+ They are added to the queue, so they are printed outside the thread.
215
+ """
216
+ logging_configurer(level, log_filename, disable_auto_logging)
217
+ logger = logging.getLogger()
218
+ while True:
219
+ task: logging.LogRecord | None = None
220
+ try:
221
+ task = queue.get(block=False)
222
+ except Empty:
223
+ time.sleep(0.1)
224
+ except InterruptedError:
225
+ pass
226
+ except Exception: # noqa: S110
227
+ pass
228
+
229
+ if task is None:
230
+ continue
231
+
232
+ logger.handle(task)
233
+ queue.task_done()
234
+
235
+
236
+ def thread_logging_configurer(queue: LOGGING_QUEUE_TYPE) -> None:
237
+ """Configure the game logger."""
238
+ h = logging.handlers.QueueHandler(queue)
239
+ root = logging.getLogger()
240
+ root.handlers.clear()
241
+ root.addHandler(h)
242
+ root.setLevel(logging.DEBUG)
243
+
244
+
245
+ def start(li: lichess.Lichess, user_profile: UserProfileType, config: Configuration, logging_level: int,
246
+ log_filename: str | None, disable_auto_logging: bool, one_game: bool = False) -> None:
247
+ """
248
+ Start lichess-bot.
249
+
250
+ :param li: Provides communication with lichess.org.
251
+ :param user_profile: Information on our bot.
252
+ :param config: The config that the bot will use.
253
+ :param logging_level: The logging level. Either `logging.INFO` or `logging.DEBUG`.
254
+ :param log_filename: The filename to write the logs to. If it is `None` then the logs aren't written to a file.
255
+ :param disable_auto_logging: Whether to disable automatic logging.
256
+ :param one_game: Whether the bot should play only one game. Only used in `test_bot/test_bot.py` to test lichess-bot.
257
+ """
258
+ logger.info(f"You're now connected to {config.url} and awaiting challenges.")
259
+ manager = multiprocessing.Manager()
260
+ challenge_queue: MULTIPROCESSING_LIST_TYPE = manager.list()
261
+ control_queue: CONTROL_QUEUE_TYPE = manager.Queue()
262
+ control_stream = multiprocessing.Process(target=watch_control_stream, args=(control_queue, li))
263
+ control_stream.start()
264
+ correspondence_pinger = multiprocessing.Process(target=do_correspondence_ping,
265
+ args=(control_queue,
266
+ seconds(config.correspondence.checkin_period)))
267
+ correspondence_pinger.start()
268
+ correspondence_queue: CORRESPONDENCE_QUEUE_TYPE = manager.Queue()
269
+
270
+ logging_queue = manager.Queue()
271
+ logging_listener = multiprocessing.Process(target=logging_listener_proc,
272
+ args=(logging_queue,
273
+ logging_level,
274
+ log_filename,
275
+ disable_auto_logging))
276
+ logging_listener.start()
277
+
278
+ pgn_queue = manager.Queue()
279
+ pgn_listener = multiprocessing.Process(target=write_pgn_records,
280
+ args=(pgn_queue,
281
+ config,
282
+ user_profile["username"]))
283
+ pgn_listener.start()
284
+
285
+ thread_logging_configurer(logging_queue)
286
+
287
+ try:
288
+ lichess_bot_main(li,
289
+ user_profile,
290
+ config,
291
+ challenge_queue,
292
+ control_queue,
293
+ correspondence_queue,
294
+ logging_queue,
295
+ pgn_queue,
296
+ one_game)
297
+ finally:
298
+ control_stream.terminate()
299
+ control_stream.join()
300
+ correspondence_pinger.terminate()
301
+ correspondence_pinger.join()
302
+ time.sleep(1.0) # Allow final messages in logging_queue to be handled.
303
+ logging_configurer(logging_level, log_filename, disable_auto_logging)
304
+ logging_listener.terminate()
305
+ logging_listener.join()
306
+ pgn_listener.terminate()
307
+ pgn_listener.join()
308
+
309
+
310
+ def log_proc_count(change: str, active_games: set[str]) -> None:
311
+ """
312
+ Log the number of active games and their IDs.
313
+
314
+ :param change: Either "Freed", "Used", or "Queued".
315
+ :param active_games: A set containing the IDs of the active games.
316
+ """
317
+ symbol = "+++" if change == "Freed" else "---"
318
+ logger.info(f"{symbol} Process {change}. Count: {len(active_games)}. IDs: {active_games or None}")
319
+
320
+
321
+ def lichess_bot_main(li: lichess.Lichess,
322
+ user_profile: UserProfileType,
323
+ config: Configuration,
324
+ challenge_queue: MULTIPROCESSING_LIST_TYPE,
325
+ control_queue: CONTROL_QUEUE_TYPE,
326
+ correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
327
+ logging_queue: LOGGING_QUEUE_TYPE,
328
+ pgn_queue: PGN_QUEUE_TYPE,
329
+ one_game: bool) -> None:
330
+ """
331
+ Handle all the games and challenges.
332
+
333
+ :param li: Provides communication with lichess.org.
334
+ :param user_profile: Information on our bot.
335
+ :param config: The config that the bot will use.
336
+ :param challenge_queue: The queue containing the challenges.
337
+ :param control_queue: The queue containing all the events.
338
+ :param correspondence_queue: The queue containing the correspondence games.
339
+ :param logging_queue: The logging queue. Used by `logging_listener_proc`.
340
+ :param pgn_queue: The queue containing the PGN games.
341
+ :param one_game: Whether the bot should play only one game. Only used in `test_bot/test_bot.py` to test lichess-bot.
342
+ """
343
+ max_games = config.challenge.concurrency
344
+
345
+ one_game_completed = False
346
+
347
+ all_games = li.get_ongoing_games() or []
348
+ prune_takeback_records(all_games)
349
+ startup_correspondence_games = [game["gameId"]
350
+ for game in all_games
351
+ if game["speed"] == "correspondence"]
352
+ active_games = {game["gameId"]
353
+ for game in all_games
354
+ if game["gameId"] not in startup_correspondence_games}
355
+ low_time_games: list[GameType] = []
356
+
357
+ last_check_online_time = Timer(hours(1))
358
+ matchmaker = matchmaking.Matchmaking(li, config, user_profile)
359
+ matchmaker.show_earliest_challenge_time()
360
+
361
+ play_game_args = PlayGameArgsType(li=li, control_queue=control_queue, user_profile=user_profile,
362
+ config=config, challenge_queue=challenge_queue,
363
+ correspondence_queue=correspondence_queue, logging_queue=logging_queue,
364
+ pgn_queue=pgn_queue)
365
+
366
+ recent_bot_challenges: defaultdict[str, list[Timer]] = defaultdict(list)
367
+
368
+ online_block_list = OnlineBlocklist(config.challenge.online_block_list)
369
+
370
+ if config.quit_after_all_games_finish:
371
+ logger.info("When quitting, lichess-bot will first wait for all running games to finish.")
372
+ logger.info("Press Ctrl-C twice to quit immediately.")
373
+
374
+ with multiprocessing.pool.Pool(max_games + 1) as pool:
375
+ while not (stop.terminated or (one_game and one_game_completed) or stop.restart):
376
+ event = next_event(control_queue)
377
+ if not event:
378
+ continue
379
+
380
+ if event["type"] == "terminated":
381
+ stop.restart = True
382
+ control_queue.task_done()
383
+ break
384
+
385
+ if event["type"] == "local_game_done":
386
+ active_games.discard(event["game"]["id"])
387
+ matchmaker.game_done()
388
+ log_proc_count("Freed", active_games)
389
+ one_game_completed = True
390
+ elif event["type"] == "challenge":
391
+ handle_challenge(event,
392
+ li,
393
+ challenge_queue,
394
+ config.challenge,
395
+ user_profile,
396
+ recent_bot_challenges,
397
+ online_block_list)
398
+ elif event["type"] == "challengeDeclined":
399
+ matchmaker.declined_challenge(event)
400
+ elif event["type"] == "challengeCanceled":
401
+ active_games.discard(event["challenge"]["id"])
402
+ log_proc_count("Freed", active_games)
403
+ elif event["type"] == "gameStart":
404
+ matchmaker.accepted_challenge(event)
405
+ start_game(event,
406
+ pool,
407
+ play_game_args,
408
+ config,
409
+ startup_correspondence_games,
410
+ correspondence_queue,
411
+ active_games,
412
+ low_time_games)
413
+
414
+ start_low_time_games(low_time_games, active_games, max_games, pool, play_game_args)
415
+ check_in_on_correspondence_games(pool,
416
+ event,
417
+ correspondence_queue,
418
+ challenge_queue,
419
+ play_game_args,
420
+ active_games,
421
+ max_games)
422
+ accept_challenges(li, challenge_queue, active_games, max_games)
423
+ matchmaker.challenge(active_games, challenge_queue, max_games)
424
+ check_online_status(li, user_profile, last_check_online_time)
425
+
426
+ control_queue.task_done()
427
+
428
+ close_pool(pool, active_games, config)
429
+
430
+
431
+ def close_pool(pool: POOL_TYPE, active_games: set[str], config: Configuration) -> None:
432
+ """Shut down pool after possibly waiting on games to finish depending on the configuration."""
433
+ if config.quit_after_all_games_finish:
434
+ if active_games:
435
+ logger.info("Waiting for games to finish before quitting.")
436
+ pool.close()
437
+ pool.join()
438
+
439
+
440
+ def next_event(control_queue: CONTROL_QUEUE_TYPE) -> EventType:
441
+ """Get the next event from the control queue."""
442
+ try:
443
+ event = control_queue.get()
444
+ if event is None:
445
+ return {}
446
+ except InterruptedError:
447
+ return {}
448
+
449
+ if "type" not in event:
450
+ logger.warning("Unable to handle response from lichess.org:")
451
+ logger.warning(event)
452
+ control_queue.task_done()
453
+ return {}
454
+
455
+ if event.get("type") != "ping":
456
+ logger.debug(f"Event: {event}")
457
+
458
+ return event
459
+
460
+
461
+ correspondence_games_to_start = 0
462
+
463
+
464
+ def check_in_on_correspondence_games(pool: POOL_TYPE,
465
+ event: EventType,
466
+ correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
467
+ challenge_queue: MULTIPROCESSING_LIST_TYPE,
468
+ play_game_args: PlayGameArgsType,
469
+ active_games: set[str],
470
+ max_games: int) -> None:
471
+ """Start correspondence games."""
472
+ global correspondence_games_to_start
473
+
474
+ if event["type"] == "correspondence_ping":
475
+ correspondence_games_to_start = correspondence_queue.qsize()
476
+ elif event["type"] != "local_game_done":
477
+ return
478
+
479
+ if challenge_queue:
480
+ return
481
+
482
+ while len(active_games) < max_games and correspondence_games_to_start > 0:
483
+ game_id = correspondence_queue.get_nowait()
484
+ correspondence_games_to_start -= 1
485
+ correspondence_queue.task_done()
486
+ start_game_thread(active_games, game_id, play_game_args, pool)
487
+
488
+
489
+ def start_low_time_games(low_time_games: list[GameType], active_games: set[str], max_games: int,
490
+ pool: POOL_TYPE, play_game_args: PlayGameArgsType) -> None:
491
+ """Start the games based on how much time we have left."""
492
+ low_time_games.sort(key=lambda g: g.get("secondsLeft", math.inf))
493
+ while low_time_games and len(active_games) < max_games:
494
+ game_id = low_time_games.pop(0)["id"]
495
+ start_game_thread(active_games, game_id, play_game_args, pool)
496
+
497
+
498
+ def accept_challenges(li: lichess.Lichess, challenge_queue: MULTIPROCESSING_LIST_TYPE, active_games: set[str],
499
+ max_games: int) -> None:
500
+ """Accept a challenge."""
501
+ while len(active_games) < max_games and challenge_queue:
502
+ chlng = challenge_queue.pop(0)
503
+ if chlng.from_self:
504
+ continue
505
+
506
+ try:
507
+ logger.info(f"Accept {chlng}")
508
+ li.accept_challenge(chlng.id)
509
+ active_games.add(chlng.id)
510
+ log_proc_count("Queued", active_games)
511
+ except (HTTPError, ReadTimeout) as exception:
512
+ if isinstance(exception, HTTPError) and exception.response is not None and exception.response.status_code == 404:
513
+ logger.info(f"Skip missing {chlng}")
514
+
515
+
516
+ def check_online_status(li: lichess.Lichess, user_profile: UserProfileType, last_check_online_time: Timer) -> None:
517
+ """Check if lichess.org thinks the bot is online or not. If it isn't, we restart it."""
518
+ if last_check_online_time.is_expired():
519
+ try:
520
+ if not li.is_online(user_profile["id"]):
521
+ logger.info("Will restart lichess-bot")
522
+ stop.restart = True
523
+ last_check_online_time.reset()
524
+ except (HTTPError, ReadTimeout):
525
+ pass
526
+
527
+
528
+ def sort_challenges(challenge_queue: MULTIPROCESSING_LIST_TYPE, challenge_config: Configuration) -> None:
529
+ """
530
+ Sort the challenges.
531
+
532
+ They can be sorted either by rating (the best challenger is accepted first),
533
+ or by time (the first challenger is accepted first). The bot can also
534
+ prioritize playing against humans or bots.
535
+ """
536
+ challenge_list = list(challenge_queue)
537
+ if challenge_config.sort_by == "best":
538
+ challenge_list.sort(key=lambda challenger: challenger.score(), reverse=True)
539
+ if challenge_config.preference != "none":
540
+ challenge_list.sort(key=lambda challenger: challenger.challenger.is_bot, reverse=challenge_config.preference == "bot")
541
+ challenge_queue[:] = challenge_list
542
+
543
+
544
+ def game_is_active(li: lichess.Lichess, game_id: str) -> bool:
545
+ """Determine if a game is still being played."""
546
+ active_games = li.get_ongoing_games()
547
+ if active_games is None:
548
+ return True
549
+ return game_id in (ongoing_game["gameId"] for ongoing_game in active_games)
550
+
551
+
552
+ def start_game_thread(active_games: set[str], game_id: str, play_game_args: PlayGameArgsType, pool: POOL_TYPE) -> None:
553
+ """Start a game thread."""
554
+ active_games.add(game_id)
555
+ log_proc_count("Used", active_games)
556
+ play_game_args["game_id"] = game_id
557
+
558
+ def game_error_handler(error: BaseException) -> None:
559
+ logger.exception("Game ended due to error:", exc_info=error)
560
+ control_queue = play_game_args["control_queue"]
561
+ pgn_queue = play_game_args["pgn_queue"]
562
+ li = play_game_args["li"]
563
+ control_queue.put_nowait({"type": "local_game_done", "game": {"id": game_id}})
564
+ pgn_queue.put_nowait({"game": {"id": game_id,
565
+ "pgn": li.get_game_pgn(game_id),
566
+ "complete": not game_is_active(li, game_id)}})
567
+
568
+ pool.apply_async(play_game,
569
+ kwds=play_game_args,
570
+ error_callback=game_error_handler)
571
+
572
+
573
+ def start_game(event: EventType,
574
+ pool: POOL_TYPE,
575
+ play_game_args: PlayGameArgsType,
576
+ config: Configuration,
577
+ startup_correspondence_games: list[str],
578
+ correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
579
+ active_games: set[str],
580
+ low_time_games: list[GameType]) -> None:
581
+ """
582
+ Start a game.
583
+
584
+ :param event: The gameStart event.
585
+ :param pool: The thread pool that the game is added to, so they can be run asynchronously.
586
+ :param play_game_args: The args passed to `play_game`.
587
+ :param config: The config the bot will use.
588
+ :param startup_correspondence_games: A list of correspondence games that have to be started.
589
+ :param correspondence_queue: The queue that correspondence games are added to, to be started.
590
+ :param active_games: A set of all the games that aren't correspondence games.
591
+ :param low_time_games: A list of games, in which we don't have much time remaining.
592
+ """
593
+ game_id = event["game"]["id"]
594
+ if game_id in startup_correspondence_games:
595
+ if enough_time_to_queue(event, config):
596
+ logger.info(f"--- Enqueue {config.url + game_id}")
597
+ correspondence_queue.put_nowait(game_id)
598
+ else:
599
+ logger.info(f"--- Will start {config.url + game_id} as soon as possible")
600
+ low_time_games.append(event["game"])
601
+ startup_correspondence_games.remove(game_id)
602
+ else:
603
+ start_game_thread(active_games, game_id, play_game_args, pool)
604
+
605
+
606
+ def enough_time_to_queue(event: EventType, config: Configuration) -> bool:
607
+ """Check whether the correspondence must be started now or if it can wait."""
608
+ corr_cfg = config.correspondence
609
+ minimum_time = (corr_cfg.checkin_period + corr_cfg.move_time) * 10
610
+ game = event["game"]
611
+ return not game["isMyTurn"] or game.get("secondsLeft", math.inf) > minimum_time
612
+
613
+
614
+ def handle_challenge(event: EventType, li: lichess.Lichess, challenge_queue: MULTIPROCESSING_LIST_TYPE,
615
+ challenge_config: Configuration, user_profile: UserProfileType,
616
+ recent_bot_challenges: defaultdict[str, list[Timer]], online_block_list: OnlineBlocklist) -> None:
617
+ """Handle incoming challenges. It either accepts, declines, or queues them to accept later."""
618
+ chlng = model.Challenge(event["challenge"], user_profile)
619
+ if chlng.from_self:
620
+ return
621
+
622
+ active_games = li.get_ongoing_games() or []
623
+ opponent_engagements = Counter(game["opponent"]["username"] for game in active_games)
624
+ opponent_engagements.update(challenge.challenger.name for challenge in challenge_queue)
625
+
626
+ online_block_list.refresh()
627
+
628
+ is_supported, decline_reason = chlng.is_supported(challenge_config,
629
+ recent_bot_challenges,
630
+ opponent_engagements,
631
+ online_block_list,
632
+ user_profile)
633
+ if is_supported:
634
+ challenge_queue.append(chlng)
635
+ sort_challenges(challenge_queue, challenge_config)
636
+ time_window = challenge_config.recent_bot_challenge_age
637
+ if time_window is not None:
638
+ recent_bot_challenges[chlng.challenger.name].append(Timer(seconds(time_window)))
639
+ else:
640
+ li.decline_challenge(chlng.id, reason=decline_reason)
641
+
642
+
643
+ @backoff.on_exception(backoff.expo, BaseException, max_time=600, giveup=lichess.is_final, # type: ignore[arg-type]
644
+ on_backoff=lichess.backoff_handler)
645
+ def play_game(li: lichess.Lichess,
646
+ game_id: str,
647
+ control_queue: CONTROL_QUEUE_TYPE,
648
+ user_profile: UserProfileType,
649
+ config: Configuration,
650
+ challenge_queue: MULTIPROCESSING_LIST_TYPE,
651
+ correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
652
+ logging_queue: LOGGING_QUEUE_TYPE,
653
+ pgn_queue: PGN_QUEUE_TYPE) -> None:
654
+ """
655
+ Play a game.
656
+
657
+ :param li: Provides communication with lichess.org.
658
+ :param game_id: The id of the game.
659
+ :param control_queue: The control queue that contains events (adds `local_game_done` to the queue).
660
+ :param user_profile: Information on our bot.
661
+ :param config: The config that the bot will use.
662
+ :param challenge_queue: The queue containing the challenges.
663
+ :param correspondence_queue: The queue containing the correspondence games.
664
+ :param logging_queue: The logging queue. Used by `logging_listener_proc`.
665
+ :param pgn_queue: The queue containing the PGN games.
666
+ """
667
+ thread_logging_configurer(logging_queue)
668
+ logger = logging.getLogger(__name__)
669
+
670
+ with li.get_game_stream(game_id) as response:
671
+ lines = response.iter_lines()
672
+
673
+ # Initial response of stream will be the full game info. Store it.
674
+ initial_state = json.loads(next(lines).decode("utf-8"))
675
+ logger.debug(f"Initial state: {initial_state}")
676
+ abort_time = seconds(config.abort_time)
677
+ game = model.Game(initial_state, user_profile["username"], li.baseUrl, abort_time)
678
+
679
+ with engine_wrapper.create_engine(config, game) as engine:
680
+ engine.get_opponent_info(game)
681
+ logger.debug(f"The engine for game {game_id} has pid={engine.get_pid()}")
682
+ conversation = Conversation(game, engine, li, __version__, challenge_queue)
683
+
684
+ logger.info(f"+++ {game}")
685
+
686
+ is_correspondence = game.speed == "correspondence"
687
+ correspondence_cfg = config.correspondence
688
+ correspondence_move_time = seconds(correspondence_cfg.move_time)
689
+ correspondence_disconnect_time = seconds(correspondence_cfg.disconnect_time)
690
+
691
+ engine_cfg = config.engine
692
+ ponder_cfg = correspondence_cfg if is_correspondence else engine_cfg
693
+ can_ponder = ponder_cfg.uci_ponder or ponder_cfg.ponder
694
+ move_overhead = msec(config.move_overhead)
695
+ delay = msec(config.rate_limiting_delay)
696
+
697
+ takebacks_accepted = read_takeback_record(game)
698
+ max_takebacks_accepted = config.max_takebacks_accepted
699
+
700
+ keyword_map: defaultdict[str, str] = defaultdict(str, me=game.me.name, opponent=game.opponent.name)
701
+ hello = get_greeting("hello", config.greeting, keyword_map)
702
+ goodbye = get_greeting("goodbye", config.greeting, keyword_map)
703
+ hello_spectators = get_greeting("hello_spectators", config.greeting, keyword_map)
704
+ goodbye_spectators = get_greeting("goodbye_spectators", config.greeting, keyword_map)
705
+
706
+ disconnect_time = correspondence_disconnect_time if not game.state.get("moves") else seconds(0)
707
+ prior_game = None
708
+ board = chess.Board()
709
+ game_stream = itertools.chain([json.dumps(game.state).encode("utf-8")], lines)
710
+ quit_after_all_games_finish = config.quit_after_all_games_finish
711
+ stay_in_game = True
712
+ while stay_in_game and (not stop.terminated or quit_after_all_games_finish) and not stop.force_quit:
713
+ move_attempted = False
714
+ try:
715
+ upd = next_update(game_stream)
716
+ u_type = upd["type"] if upd else "ping"
717
+ if u_type == "chatLine":
718
+ conversation.react(ChatLine(upd))
719
+ elif u_type == "gameState":
720
+ game.state = upd
721
+ board = setup_board(game)
722
+ takeback_field = game.state.get("btakeback") if game.is_white else game.state.get("wtakeback")
723
+
724
+ if not is_game_over(game) and is_engine_move(game, prior_game, board):
725
+ disconnect_time = correspondence_disconnect_time
726
+ say_hello(conversation, hello, hello_spectators, board)
727
+ setup_timer = Timer()
728
+ print_move_number(board)
729
+ move_attempted = True
730
+ engine.play_move(board,
731
+ game,
732
+ li,
733
+ setup_timer,
734
+ move_overhead,
735
+ can_ponder,
736
+ is_correspondence,
737
+ correspondence_move_time,
738
+ engine_cfg,
739
+ fake_think_time(config, board, game))
740
+ time.sleep(to_seconds(delay))
741
+ elif is_game_over(game):
742
+ tell_user_game_result(game, board)
743
+ engine.send_game_result(game, board)
744
+ conversation.send_message("player", goodbye)
745
+ conversation.send_message("spectator", goodbye_spectators)
746
+ elif (takeback_field
747
+ and not bot_to_move(game, board)
748
+ and li.accept_takeback(game.id, takebacks_accepted < max_takebacks_accepted)):
749
+ takebacks_accepted += 1
750
+ record_takeback(game, takebacks_accepted)
751
+ engine.discard_last_move_commentary()
752
+
753
+ wbtime = upd[engine_wrapper.wbtime(board)]
754
+ wbinc = upd[engine_wrapper.wbinc(board)]
755
+ terminate_time = msec(wbtime) + msec(wbinc) + seconds(60)
756
+ game.ping(abort_time, terminate_time, disconnect_time)
757
+ prior_game = copy.deepcopy(game)
758
+ elif u_type == "ping" and should_exit_game(board, game, prior_game, li, is_correspondence):
759
+ stay_in_game = False
760
+ except (HTTPError, ReadTimeout, RemoteDisconnected, ChunkedEncodingError, RequestsConnectionError,
761
+ StopIteration) as e:
762
+ stopped = isinstance(e, StopIteration)
763
+ stay_in_game = not stopped and (move_attempted or game_is_active(li, game.id))
764
+
765
+ pgn_record = try_get_pgn_game_record(li, config, game, board, engine)
766
+ final_queue_entries(control_queue, correspondence_queue, game, is_correspondence, pgn_record, pgn_queue)
767
+ delete_takeback_record(game)
768
+
769
+
770
+ def read_takeback_record(game: model.Game) -> int:
771
+ """Read the number of move takeback requests accepted in a game."""
772
+ try:
773
+ with open(takeback_record_file_name(game.id)) as takeback_file:
774
+ return int(takeback_file.read())
775
+ except Exception:
776
+ return 0
777
+
778
+
779
+ def record_takeback(game: model.Game, accepted_count: int) -> None:
780
+ """Record the number of move takeback requests accepted in a game."""
781
+ with open(takeback_record_file_name(game.id), "w") as takeback_file:
782
+ takeback_file.write(str(accepted_count))
783
+
784
+
785
+ def delete_takeback_record(game: model.Game) -> None:
786
+ """Delete the takeback record from a game if it has finished."""
787
+ if is_game_over(game):
788
+ with contextlib.suppress(Exception):
789
+ os.remove(takeback_record_file_name(game.id))
790
+
791
+
792
+ def prune_takeback_records(all_games: list[GameType]) -> None:
793
+ """Delete takeback records from games that have ended."""
794
+ active_game_ids = {game["gameId"] for game in all_games}
795
+ takeback_file_template = takeback_record_file_name("*")
796
+ prefix, suffix = takeback_file_template.split("*")
797
+ for takeback_file_name in glob.glob(takeback_file_template):
798
+ game_id = takeback_file_name.removeprefix(prefix).removesuffix(suffix)
799
+ if game_id not in active_game_ids:
800
+ with contextlib.suppress(Exception):
801
+ os.remove(takeback_file_name)
802
+
803
+
804
+ def takeback_record_file_name(game_id: str) -> str:
805
+ """Get the file name for recording the number of move takebacks accepted."""
806
+ return os.path.join(auto_log_directory, f"takeback-count-{game_id}.txt")
807
+
808
+
809
+ def get_greeting(greeting: str, greeting_cfg: Configuration, keyword_map: defaultdict[str, str]) -> str:
810
+ """Get the greeting to send to the chat."""
811
+ greeting_text: str = greeting_cfg.lookup(greeting)
812
+ return greeting_text.format_map(keyword_map)
813
+
814
+
815
+ def say_hello(conversation: Conversation, hello: str, hello_spectators: str, board: chess.Board) -> None:
816
+ """Send the greetings to the chat rooms."""
817
+ if len(board.move_stack) < 2:
818
+ conversation.send_message("player", hello)
819
+ conversation.send_message("spectator", hello_spectators)
820
+
821
+
822
+ def fake_think_time(config: Configuration, board: chess.Board, game: model.Game) -> datetime.timedelta:
823
+ """Calculate how much time we should wait for fake_think_time."""
824
+ sleep = seconds(0.0)
825
+
826
+ if config.fake_think_time and len(board.move_stack) > 9:
827
+ remaining = max(seconds(0), game.my_remaining_time() - msec(config.move_overhead))
828
+ delay = remaining * 0.025
829
+ accel = 0.99 ** (len(board.move_stack) - 10)
830
+ sleep = delay * accel
831
+
832
+ return sleep
833
+
834
+
835
+ def print_move_number(board: chess.Board) -> None:
836
+ """Log the move number."""
837
+ logger.info("")
838
+ logger.info(f"move: {len(board.move_stack) // 2 + 1}")
839
+
840
+
841
+ def next_update(lines: Iterator[bytes]) -> GameEventType:
842
+ """Get the next game state."""
843
+ binary_chunk = next(lines)
844
+ upd = cast(GameEventType, json.loads(binary_chunk.decode("utf-8"))) if binary_chunk else {}
845
+ if upd:
846
+ logger.debug(f"Game state: {upd}")
847
+ return upd
848
+
849
+
850
+ def setup_board(game: model.Game) -> chess.Board:
851
+ """Set up the board."""
852
+ if game.variant_name.lower() == "chess960":
853
+ board = chess.Board(game.initial_fen, chess960=True)
854
+ elif game.variant_name == "From Position":
855
+ fen = cast(str, game.initial_fen)
856
+ board = chess.Board(fen, chess960=model.is_chess_960(fen))
857
+
858
+ else:
859
+ VariantBoard = find_variant(game.variant_name)
860
+ board = VariantBoard()
861
+
862
+ for move in game.state["moves"].split():
863
+ try:
864
+ board.push_uci(move)
865
+ except ValueError:
866
+ logger.exception(f"Ignoring illegal move {move} on board {board.fen()}")
867
+
868
+ return board
869
+
870
+
871
+ def is_engine_move(game: model.Game, prior_game: model.Game | None, board: chess.Board) -> bool:
872
+ """Check whether it is the engine's turn."""
873
+ return game_changed(game, prior_game) and bot_to_move(game, board)
874
+
875
+
876
+ def bot_to_move(game: model.Game, board: chess.Board) -> bool:
877
+ """
878
+ Determine whether it is the bot's move on the given board.
879
+
880
+ This only determines if the board state shows the bot is on move. It does not check if the board state has changed.
881
+ Messages from lichess can contain repeat board states if another game aspect has changed (draw offer, takeback offer,
882
+ etc.). Use is_engine_move() to determine if the engine should play a move.
883
+ """
884
+ return game.is_white == (board.turn == chess.WHITE)
885
+
886
+
887
+ def is_game_over(game: model.Game) -> bool:
888
+ """Check whether the game is over."""
889
+ status = game.state["status"]
890
+ return status != "started"
891
+
892
+
893
+ def should_exit_game(board: chess.Board, game: model.Game, prior_game: model.Game | None, li: lichess.Lichess,
894
+ is_correspondence: bool) -> bool:
895
+ """Whether we should exit a game."""
896
+ if (is_correspondence
897
+ and not is_engine_move(game, prior_game, board)
898
+ and game.should_disconnect_now()):
899
+ return True
900
+ if game.should_abort_now():
901
+ logger.info(f"Aborting {game.url()} by lack of activity")
902
+ li.abort(game.id)
903
+ return True
904
+ if game.should_terminate_now():
905
+ logger.info(f"Terminating {game.url()} by lack of activity")
906
+ if game.is_abortable():
907
+ li.abort(game.id)
908
+ return True
909
+ return False
910
+
911
+
912
+ def final_queue_entries(control_queue: CONTROL_QUEUE_TYPE, correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
913
+ game: model.Game, is_correspondence: bool, pgn_record: str, pgn_queue: PGN_QUEUE_TYPE) -> None:
914
+ """
915
+ Log the game that ended or we disconnected from, and sends a `local_game_done` for the game.
916
+
917
+ If this is an unfinished correspondence game, put it in a queue to resume later.
918
+ """
919
+ if is_correspondence and not is_game_over(game):
920
+ logger.info(f"--- Disconnecting from {game.url()}")
921
+ correspondence_queue.put_nowait(game.id)
922
+ else:
923
+ logger.info(f"--- {game.url()} Game over")
924
+
925
+ control_queue.put_nowait({"type": "local_game_done", "game": {"id": game.id}})
926
+ pgn_queue.put_nowait({"game": {"id": game.id,
927
+ "pgn": pgn_record,
928
+ "complete": is_game_over(game)}})
929
+
930
+
931
+ def game_changed(current_game: model.Game, prior_game: model.Game | None) -> bool:
932
+ """Check whether the current game state is different from the previous game state."""
933
+ if prior_game is None:
934
+ return True
935
+
936
+ current_game_moves_str = current_game.state["moves"]
937
+ prior_game_moves_str = prior_game.state["moves"]
938
+ return current_game_moves_str != prior_game_moves_str
939
+
940
+
941
+ def tell_user_game_result(game: model.Game, board: chess.Board) -> None:
942
+ """Log the game result."""
943
+ winner = game.state.get("winner")
944
+ termination = game.state.get("status")
945
+
946
+ winning_name = game.white.name if winner == "white" else game.black.name
947
+ losing_name = game.white.name if winner == "black" else game.black.name
948
+
949
+ if winner is not None:
950
+ logger.info(f"{winning_name} won!")
951
+ elif termination in [model.Termination.DRAW, model.Termination.TIMEOUT]:
952
+ logger.info("Game ended in a draw.")
953
+ else:
954
+ logger.info("Game adjourned.")
955
+
956
+ simple_endings: dict[str, str] = {model.Termination.MATE: "Game won by checkmate.",
957
+ model.Termination.RESIGN: f"{losing_name} resigned.",
958
+ model.Termination.ABORT: "Game aborted."}
959
+
960
+ if termination in simple_endings:
961
+ logger.info(simple_endings[termination])
962
+ elif termination == model.Termination.DRAW:
963
+ draw_results = [(board.is_fifty_moves(), "Game drawn by 50-move rule."),
964
+ (board.is_repetition(), "Game drawn by threefold repetition."),
965
+ (board.is_insufficient_material(), "Game drawn from insufficient material."),
966
+ (board.is_stalemate(), "Game drawn by stalemate."),
967
+ (True, "Game drawn by agreement.")]
968
+ messages = [draw_message for is_result, draw_message in draw_results if is_result]
969
+ logger.info(messages[0])
970
+ elif termination == model.Termination.TIMEOUT:
971
+ if winner:
972
+ logger.info(f"{losing_name} forfeited on time.")
973
+ else:
974
+ timeout_name = game.white.name if game.state.get("wtime") == 0 else game.black.name
975
+ other_name = game.white.name if timeout_name == game.black.name else game.black.name
976
+ logger.info(f"{timeout_name} ran out of time, but {other_name} did not have enough material to mate.")
977
+ elif termination:
978
+ logger.info(f"Game ended by {termination}")
979
+
980
+
981
+ def try_get_pgn_game_record(li: lichess.Lichess, config: Configuration, game: model.Game, board: chess.Board,
982
+ engine: engine_wrapper.EngineWrapper) -> str:
983
+ """
984
+ Call `print_pgn_game_record` to write the game to a PGN file and handle errors raised by it.
985
+
986
+ :param li: Provides communication with lichess.org.
987
+ :param config: The config that the bot will use.
988
+ :param game: Contains information about the game (e.g. the players' names).
989
+ :param board: The board. Contains the moves.
990
+ :param engine: The engine. Contains information about the moves (e.g. eval, PV, depth).
991
+ """
992
+ try:
993
+ return pgn_game_record(li, config, game, board, engine)
994
+ except Exception:
995
+ logger.exception("Error writing game record:")
996
+ return ""
997
+
998
+
999
+ def pgn_game_record(li: lichess.Lichess, config: Configuration, game: model.Game, board: chess.Board,
1000
+ engine: engine_wrapper.EngineWrapper) -> str:
1001
+ """
1002
+ Return the text of the game's PGN.
1003
+
1004
+ :param li: Provides communication with lichess.org.
1005
+ :param config: The config that the bot will use.
1006
+ :param game: Contains information about the game (e.g. the players' names).
1007
+ :param board: The board. Contains the moves.
1008
+ :param engine: The engine. Contains information about the moves (e.g. eval, PV, depth).
1009
+ """
1010
+ if not config.pgn_directory:
1011
+ return ""
1012
+
1013
+ lichess_game_record = chess.pgn.read_game(io.StringIO(li.get_game_pgn(game.id))) or chess.pgn.Game()
1014
+ try:
1015
+ # Recall previously written PGN file to retain engine evaluations.
1016
+ previous_game_path = get_game_file_path(config,
1017
+ game.id,
1018
+ game.white.name,
1019
+ game.black.name,
1020
+ game.me.name,
1021
+ is_game_over(game),
1022
+ force_single=True)
1023
+ with open(previous_game_path) as game_data:
1024
+ game_record = chess.pgn.read_game(game_data) or lichess_game_record
1025
+ game_record.headers.update(lichess_game_record.headers)
1026
+ except FileNotFoundError:
1027
+ game_record = lichess_game_record
1028
+
1029
+ fill_missing_pgn_headers(game_record, game)
1030
+
1031
+ current_node: chess.pgn.Game | chess.pgn.ChildNode = game_record.game()
1032
+ lichess_node: chess.pgn.Game | chess.pgn.ChildNode = lichess_game_record.game()
1033
+ for index, move in enumerate(board.move_stack):
1034
+ next_node = current_node.next()
1035
+ if next_node is None or next_node.move != move:
1036
+ current_node = current_node.add_main_variation(move)
1037
+ else:
1038
+ current_node = next_node
1039
+
1040
+ next_lichess_node = lichess_node.next()
1041
+ if next_lichess_node:
1042
+ lichess_node = next_lichess_node
1043
+ current_node.set_clock(lichess_node.clock())
1044
+ if current_node.comment != lichess_node.comment:
1045
+ current_node.comment = f"{current_node.comment} {lichess_node.comment}".strip()
1046
+
1047
+ commentary = engine.comment_for_board_index(index)
1048
+ pv_node = current_node.parent.add_line(commentary["pv"]) if "pv" in commentary else current_node
1049
+ pv_node.set_eval(commentary.get("score"), commentary.get("depth"))
1050
+
1051
+ pgn_writer = chess.pgn.StringExporter()
1052
+ return game_record.accept(pgn_writer)
1053
+
1054
+
1055
+ def get_game_file_path(config: Configuration,
1056
+ game_id: str,
1057
+ white_name: str,
1058
+ black_name: str,
1059
+ user_name: str,
1060
+ game_is_over: bool,
1061
+ *, force_single: bool = False) -> str:
1062
+ """Return the path of the file where the game record will be written."""
1063
+ def create_valid_path(s: str) -> str:
1064
+ illegal = '<>:"/\\|?*'
1065
+ return os.path.join(config.pgn_directory, "".join(c for c in s if c not in illegal))
1066
+
1067
+ if config.pgn_file_grouping == "game" or not game_is_over or force_single:
1068
+ return create_valid_path(f"{white_name} vs {black_name} - {game_id}.pgn")
1069
+ elif config.pgn_file_grouping == "opponent":
1070
+ opponent_name = white_name if user_name == black_name else black_name
1071
+ return create_valid_path(f"{user_name} games vs. {opponent_name}.pgn")
1072
+ else: # config.pgn_file_grouping == "all"
1073
+ return create_valid_path(f"{user_name} games.pgn")
1074
+
1075
+
1076
+ def fill_missing_pgn_headers(game_record: chess.pgn.Game, game: model.Game) -> None:
1077
+ """
1078
+ Fill in any missing headers in the PGN record provided by lichess.org with information from `game`.
1079
+
1080
+ :param game_record: A `chess.pgn.Game` object containing information about the game lichess.org's PGN file.
1081
+ :param game: Contains information about the game (e.g. the players' names), which is used to get the local headers.
1082
+ """
1083
+ local_headers = get_headers(game)
1084
+ for header, game_value in local_headers.items():
1085
+ record_value = game_record.headers.get(header)
1086
+ if not record_value or record_value.startswith("?") or (header == "Result" and record_value == "*"):
1087
+ game_record.headers[header] = str(game_value)
1088
+
1089
+
1090
+ def get_headers(game: model.Game) -> dict[str, str | int]:
1091
+ """
1092
+ Create local headers to be written in the PGN file.
1093
+
1094
+ :param game: Contains information about the game (e.g. the players' names).
1095
+ :return: The headers in a dict.
1096
+ """
1097
+ headers: dict[str, str | int] = {}
1098
+ headers["Event"] = game.pgn_event()
1099
+ headers["Site"] = game.short_url()
1100
+ headers["Date"] = game.game_start.strftime("%Y.%m.%d")
1101
+ headers["White"] = game.white.name or str(game.white)
1102
+ headers["Black"] = game.black.name or str(game.black)
1103
+ headers["Result"] = game.result()
1104
+
1105
+ if game.black.rating:
1106
+ headers["BlackElo"] = game.black.rating
1107
+ if game.black.title:
1108
+ headers["BlackTitle"] = game.black.title
1109
+
1110
+ if game.perf_name != "correspondence":
1111
+ headers["TimeControl"] = game.time_control()
1112
+
1113
+ headers["UTCDate"] = headers["Date"]
1114
+ headers["UTCTime"] = game.game_start.strftime("%H:%M:%S")
1115
+ headers["Variant"] = game.variant_name
1116
+
1117
+ if game.initial_fen and game.initial_fen != "startpos":
1118
+ headers["Setup"] = 1
1119
+ headers["FEN"] = game.initial_fen
1120
+
1121
+ if game.white.rating:
1122
+ headers["WhiteElo"] = game.white.rating
1123
+ if game.white.title:
1124
+ headers["WhiteTitle"] = game.white.title
1125
+
1126
+ return headers
1127
+
1128
+
1129
+ def save_pgn_record(event: EventType, config: Configuration, user_name: str) -> None:
1130
+ """
1131
+ Write the game PGN record to a file.
1132
+
1133
+ :param event: A local_game_done event from the control queue.
1134
+ :param config: The user's bot configuration.
1135
+ :param user_name: The bot's name.
1136
+ """
1137
+ pgn = event["game"]["pgn"]
1138
+ pgn_headers = chess.pgn.read_headers(io.StringIO(pgn))
1139
+ if not config.pgn_directory or pgn_headers is None:
1140
+ return
1141
+
1142
+ game_id = event["game"]["id"]
1143
+ white_name = pgn_headers["White"]
1144
+ black_name = pgn_headers["Black"]
1145
+ game_is_over = event["game"]["complete"]
1146
+
1147
+ os.makedirs(config.pgn_directory, exist_ok=True)
1148
+ game_path = get_game_file_path(config, game_id, white_name, black_name, user_name, game_is_over)
1149
+ single_game_path = get_game_file_path(config, game_id, white_name, black_name, user_name, game_is_over, force_single=True)
1150
+ write_mode = "w" if game_path == single_game_path else "a"
1151
+ logger.debug(f"Writing PGN game record to: {game_path}")
1152
+ with open(game_path, write_mode) as game_file:
1153
+ game_file.write(pgn + "\n\n")
1154
+
1155
+ if os.path.exists(single_game_path) and game_path != single_game_path:
1156
+ os.remove(single_game_path)
1157
+
1158
+
1159
+ def intro() -> str:
1160
+ """Return the intro string."""
1161
+ return fr"""
1162
+ . _/|
1163
+ . // o\
1164
+ . || ._) lichess-bot {__version__} on {platform.system()} {platform.release()}
1165
+ . //__\
1166
+ . )___( Play on Lichess with a bot
1167
+ """
1168
+
1169
+
1170
+ auto_log_directory = "lichess_bot_auto_logs"
1171
+
1172
+
1173
+ def log_python_and_libraries() -> None:
1174
+ """Log the installed libraries and the python version."""
1175
+ logger.debug(f"Python version: {'.'.join(map(str, sys.version_info))}")
1176
+ text = "Installed libraries:\n"
1177
+ distributions = importlib.metadata.distributions()
1178
+ for distribution in distributions:
1179
+ text += f"{distribution.metadata['Name']}=={distribution.version}\n"
1180
+ text += "\n"
1181
+ logger.debug(text)
1182
+
1183
+
1184
+ def start_lichess_bot() -> None:
1185
+ """Parse arguments passed to lichess-bot.py and starts lichess-bot."""
1186
+ parser = argparse.ArgumentParser(description="Play on Lichess with a bot")
1187
+ parser.add_argument("-u", action="store_true", help="Upgrade your account to a bot account.")
1188
+ parser.add_argument("-v", action="store_true", help="Make output more verbose. Include all communication with lichess.")
1189
+ parser.add_argument("--config", help="Specify a configuration file (defaults to ./config.yml).")
1190
+ parser.add_argument("-l", "--logfile", help="Record all console output to a log file.", default=None)
1191
+ parser.add_argument("--disable_auto_logging", action="store_true", help="Disable automatic logging.")
1192
+ args = parser.parse_args()
1193
+
1194
+ logging_level = logging.DEBUG if args.v else logging.INFO
1195
+ logging_configurer(logging_level, args.logfile, args.disable_auto_logging)
1196
+ logger.info(intro(), extra={"highlighter": None})
1197
+
1198
+ CONFIG = load_config(args.config or "./config.yml")
1199
+ if not args.disable_auto_logging:
1200
+ with open(os.path.join(auto_log_directory, "config.log"), "w") as config_log:
1201
+ log_config(CONFIG.config, config_log.write)
1202
+ logger.info("Checking engine configuration ...")
1203
+ with engine_wrapper.create_engine(CONFIG):
1204
+ pass
1205
+ logger.info("Engine configuration OK")
1206
+
1207
+ max_retries = CONFIG.engine.online_moves.max_retries
1208
+ check_python_version()
1209
+ log_python_and_libraries()
1210
+ li = lichess.Lichess(CONFIG.token, CONFIG.url, __version__, logging_level, max_retries)
1211
+
1212
+ user_profile = li.get_profile()
1213
+ username = user_profile["username"]
1214
+ is_bot = user_profile.get("title") == "BOT"
1215
+ logger.info(f"Welcome {username}!")
1216
+
1217
+ if args.u and not is_bot:
1218
+ is_bot = upgrade_account(li)
1219
+
1220
+ if is_bot:
1221
+ start(li, user_profile, CONFIG, logging_level, args.logfile, args.disable_auto_logging)
1222
+ else:
1223
+ logger.error(f"{username} is not a bot account. Please upgrade it to a bot account!")
1224
+ logging.shutdown()
1225
+
1226
+
1227
+ def check_python_version() -> None:
1228
+ """Raise a warning or an exception if the version isn't supported or is deprecated."""
1229
+ def version_numeric(version_str: str) -> list[int]:
1230
+ return [int(n) for n in version_str.split(".")]
1231
+
1232
+ python_deprecated_version = version_numeric(versioning_info["deprecated_python_version"])
1233
+ python_good_version = version_numeric(versioning_info["minimum_python_version"])
1234
+ version_change_date = versioning_info["deprecation_date"]
1235
+ this_python_version = list(sys.version_info[0:2])
1236
+
1237
+ def version_str(version: list[int]) -> str:
1238
+ return f"Python {'.'.join(str(n) for n in version)}"
1239
+
1240
+ upgrade_request = (f"You are currently running {version_str(this_python_version)}. "
1241
+ f"Please upgrade to {version_str(python_good_version)} or newer")
1242
+ out_of_date_error = RuntimeError("A newer version of Python is required "
1243
+ f"to run this version of lichess-bot. {upgrade_request}.")
1244
+ out_of_date_warning = ("A newer version of Python will be required "
1245
+ f"on {version_change_date} to run lichess-bot. {upgrade_request} before then.")
1246
+
1247
+ this_lichess_bot_version = version_numeric(__version__)
1248
+ lichess_bot_breaking_version = list(version_change_date.timetuple()[0:3])
1249
+
1250
+ if this_python_version < python_deprecated_version:
1251
+ raise out_of_date_error
1252
+
1253
+ if this_python_version == python_deprecated_version:
1254
+ if this_lichess_bot_version < lichess_bot_breaking_version:
1255
+ logger.warning(out_of_date_warning)
1256
+ else:
1257
+ raise out_of_date_error
1258
+
1259
+
1260
+ def start_program() -> None:
1261
+ """Start lichess-bot and restart when needed."""
1262
+ multiprocessing.set_start_method("spawn")
1263
+ try:
1264
+ while should_restart():
1265
+ disable_restart()
1266
+ # Restart on network errors.
1267
+ try:
1268
+ start_lichess_bot()
1269
+ except RequestException:
1270
+ stop.restart = True
1271
+ logger.exception("Restarting lichess-bot due to a network error:")
1272
+ if stop.terminated or stop.force_quit:
1273
+ logger.info("Termination requested - stopping restart cycle")
1274
+ break
1275
+ time.sleep(10 if should_restart() else 0)
1276
+ except Exception:
1277
+ logger.exception("Quitting lichess-bot due to an error:")
lichess-bot-master/lib/lichess_types.py ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Some type hints that can be accessed by all other python files."""
2
+ from typing import Any, TypedDict, Literal, TypeAlias
3
+ from collections.abc import Callable
4
+ from chess.engine import PovWdl, PovScore, PlayResult, Limit, Opponent
5
+ from chess import Move, Board
6
+ from queue import Queue
7
+ import logging
8
+ import datetime
9
+ from enum import Enum
10
+ from types import TracebackType
11
+
12
+ COMMANDS_TYPE: TypeAlias = list[str]
13
+ MOVE: TypeAlias = PlayResult | list[Move]
14
+ CORRESPONDENCE_QUEUE_TYPE: TypeAlias = Queue[str]
15
+ LOGGING_QUEUE_TYPE: TypeAlias = Queue[logging.LogRecord]
16
+ REQUESTS_PAYLOAD_TYPE: TypeAlias = dict[str, str | int | bool]
17
+ GO_COMMANDS_TYPE: TypeAlias = dict[str, str]
18
+ EGTPATH_TYPE: TypeAlias = dict[str, str]
19
+ OPTIONS_GO_EGTB_TYPE: TypeAlias = dict[str, str | int | bool | None | EGTPATH_TYPE | GO_COMMANDS_TYPE]
20
+ OPTIONS_TYPE: TypeAlias = dict[str, str | int | bool | None]
21
+ HOMEMADE_ARGS_TYPE: TypeAlias = Limit | bool | MOVE
22
+
23
+ # Types that still use `Any`.
24
+ CONFIG_DICT_TYPE: TypeAlias = dict[str, Any]
25
+
26
+
27
+ class PerfType(TypedDict, total=False):
28
+ """Type hint for `perf`."""
29
+
30
+ games: int
31
+ rating: int
32
+ rd: int
33
+ sd: int
34
+ prov: bool
35
+ prog: int
36
+
37
+
38
+ class ProfileType(TypedDict, total=False):
39
+ """Type hint for `profile`."""
40
+
41
+ country: str
42
+ location: str
43
+ bio: str
44
+ firstName: str
45
+ lastName: str
46
+ fideRating: int
47
+ uscfRating: int
48
+ ecfRating: int
49
+ cfcRating: int
50
+ dsbRating: int
51
+ links: str
52
+
53
+
54
+ class UserProfileType(TypedDict, total=False):
55
+ """Type hint for `user_profile`."""
56
+
57
+ id: str
58
+ username: str
59
+ perfs: dict[str, PerfType]
60
+ createdAt: int
61
+ profile: ProfileType
62
+ seenAt: int
63
+ patron: int
64
+ verified: int
65
+ playTime: dict[str, int]
66
+ title: str
67
+ online: bool
68
+ url: str
69
+ followable: bool
70
+ following: bool
71
+ blocking: bool
72
+ followsYou: bool
73
+ count: dict[str, int]
74
+
75
+
76
+ class ReadableType(TypedDict):
77
+ """Type hint for `readable`."""
78
+
79
+ Evaluation: Callable[[PovScore], str]
80
+ Winrate: Callable[[PovWdl], str]
81
+ Hashfull: Callable[[int], str]
82
+ Nodes: Callable[[int], str]
83
+ Speed: Callable[[int], str]
84
+ Tbhits: Callable[[int], str]
85
+ Cpuload: Callable[[int], str]
86
+ Movetime: Callable[[int], str]
87
+
88
+
89
+ class InfoStrDict(TypedDict, total=False):
90
+ """Type hints for the readable version of the information returned by chess engines."""
91
+
92
+ score: PovScore
93
+ pv: list[Move]
94
+ depth: int
95
+ seldepth: int
96
+ time: float
97
+ nodes: int
98
+ nps: int
99
+ tbhits: int
100
+ multipv: int
101
+ currmove: str | Move
102
+ currmovenumber: int
103
+ hashfull: int
104
+ cpuload: int
105
+ refutation: str | dict[Move, list[Move]]
106
+ currline: dict[int, list[Move]]
107
+ ebf: float
108
+ wdl: PovWdl
109
+ string: str
110
+ ponderpv: str
111
+ Source: str
112
+ Pv: str
113
+
114
+
115
+ InfoDictKeys: TypeAlias = Literal["score", "pv", "depth", "seldepth", "time", "nodes", "nps", "tbhits", "multipv", "currmove",
116
+ "currmovenumber", "hashfull", "cpuload", "refutation", "currline", "ebf", "wdl", "string",
117
+ "ponderpv", "Source", "Pv"]
118
+
119
+
120
+ InfoDictValue: TypeAlias = (PovScore | list[Move] | int | float | str | Move | dict[Move, list[Move]] |
121
+ dict[int, list[Move]] | PovWdl)
122
+
123
+
124
+ class PlayerType(TypedDict, total=False):
125
+ """Type hint for information on a player."""
126
+
127
+ title: str | None
128
+ rating: int
129
+ provisional: bool
130
+ aiLevel: int
131
+ id: str
132
+ username: str
133
+ name: str
134
+ online: bool
135
+
136
+
137
+ class GameType(TypedDict, total=False):
138
+ """Type hint for game."""
139
+
140
+ gameId: str
141
+ fullId: str
142
+ color: str
143
+ fen: str
144
+ hasMoved: bool
145
+ isMyTurn: bool
146
+ lastMove: str
147
+ opponent: PlayerType
148
+ perf: str
149
+ rated: bool
150
+ secondsLeft: int
151
+ source: str
152
+ status: dict[str, str | int]
153
+ speed: str
154
+ variant: dict[str, str]
155
+ compat: dict[str, bool]
156
+ id: str
157
+ winner: str
158
+ ratingDiff: int
159
+ pgn: str
160
+ complete: bool
161
+
162
+
163
+ class TimeControlType(TypedDict, total=False):
164
+ """Type hint for time control."""
165
+
166
+ increment: int
167
+ limit: int
168
+ show: str
169
+ type: str
170
+ daysPerTurn: int
171
+ initial: int
172
+
173
+
174
+ class ChallengeType(TypedDict, total=False):
175
+ """Type hint for challenge."""
176
+
177
+ id: str
178
+ url: str
179
+ color: str
180
+ direction: str
181
+ rated: bool
182
+ speed: str
183
+ status: str
184
+ timeControl: TimeControlType
185
+ variant: dict[str, str]
186
+ challenger: PlayerType
187
+ destUser: PlayerType
188
+ perf: dict[str, str]
189
+ compat: dict[str, bool]
190
+ finalColor: str
191
+ declineReason: str
192
+ declineReasonKey: str
193
+ initialFen: str
194
+ error: str
195
+ ratelimit: dict[str, str | int]
196
+ bot_is_rate_limited: bool
197
+ opponent_is_rate_limited: bool
198
+ rate_limit_timeout: datetime.timedelta
199
+
200
+
201
+ class EventType(TypedDict, total=False):
202
+ """Type hint for event."""
203
+
204
+ type: str
205
+ game: GameType
206
+ challenge: ChallengeType
207
+ error: str | None
208
+
209
+
210
+ class GameStateType(TypedDict, total=False):
211
+ """Type hint for game state."""
212
+
213
+ type: str
214
+ moves: str
215
+ wtime: int
216
+ btime: int
217
+ winc: int
218
+ binc: int
219
+ wdraw: bool
220
+ bdraw: bool
221
+ status: str
222
+ winner: str
223
+
224
+
225
+ class GameEventType(TypedDict, total=False):
226
+ """Type hint for game event."""
227
+
228
+ type: str
229
+ id: str
230
+ rated: bool
231
+ variant: dict[str, str]
232
+ cloak: dict[str, int]
233
+ speed: str
234
+ perf: dict[str, str]
235
+ createdAt: int
236
+ white: PlayerType
237
+ black: PlayerType
238
+ initialFen: str
239
+ state: GameStateType
240
+ username: str
241
+ text: str
242
+ room: str
243
+ gone: bool
244
+ claimWinInSeconds: int
245
+ moves: str
246
+ wtime: int
247
+ btime: int
248
+ winc: int
249
+ binc: int
250
+ wdraw: bool
251
+ bdraw: bool
252
+ status: str
253
+ winner: str
254
+ clock: TimeControlType
255
+ wtakeback: bool
256
+ btakeback: bool
257
+
258
+
259
+ CONTROL_QUEUE_TYPE: TypeAlias = Queue[EventType]
260
+ PGN_QUEUE_TYPE: TypeAlias = Queue[EventType | None]
261
+
262
+
263
+ class PublicDataType(TypedDict, total=False):
264
+ """Type hint for public data."""
265
+
266
+ id: str
267
+ username: str
268
+ perfs: dict[str, PerfType]
269
+ flair: str
270
+ createdAt: int
271
+ disabled: bool
272
+ tosViolation: bool
273
+ profile: ProfileType
274
+ seenAt: int
275
+ patron: bool
276
+ verified: bool
277
+ playTime: dict[str, int]
278
+ title: str
279
+ url: str
280
+ playing: str
281
+ count: dict[str, int]
282
+ streaming: bool
283
+ streamer: dict[str, dict[str, str]]
284
+ followable: bool
285
+ following: bool
286
+ blocking: bool
287
+ followsYou: bool
288
+
289
+
290
+ class FilterType(str, Enum):
291
+ """What to do if the opponent declines our challenge."""
292
+
293
+ NONE = "none"
294
+ """Will still challenge the opponent."""
295
+ COARSE = "coarse"
296
+ """Won't challenge the opponent again."""
297
+ FINE = "fine"
298
+ """
299
+ Won't challenge the opponent to a game of the same mode, speed, and variant
300
+ based on the reason for the opponent declining the challenge.
301
+ """
302
+
303
+
304
+ class ChessDBMoveType(TypedDict, total=False):
305
+ """Type hint for a move returned by chessdb (opening & egtb)."""
306
+
307
+ uci: str
308
+ san: str
309
+ score: int
310
+ rank: int
311
+ note: str
312
+ winrate: str
313
+
314
+
315
+ class LichessPvType(TypedDict, total=False):
316
+ """Type hint for a move returned by lichess cloud analysis."""
317
+
318
+ moves: str
319
+ cp: int
320
+
321
+
322
+ class LichessExplorerGameType(TypedDict, total=False):
323
+ """Type hint for a game returned by lichess explorer."""
324
+
325
+ id: str
326
+ winner: str | None
327
+ speed: str
328
+ mode: str
329
+ black: dict[str, str | int]
330
+ white: dict[str, str | int]
331
+ year: int
332
+ month: str
333
+
334
+
335
+ class LichessEGTBMoveType(TypedDict):
336
+ """Type hint for the moves returned by the lichess egtb."""
337
+
338
+ uci: str
339
+ san: str
340
+ zeroing: bool
341
+ checkmate: bool
342
+ stalemate: bool
343
+ variant_win: bool
344
+ variant_loss: bool
345
+ insufficient_material: bool
346
+ dtz: int
347
+ precise_dtz: int | None
348
+ dtm: int | None
349
+ category: str
350
+
351
+
352
+ class OnlineMoveType(TypedDict):
353
+ """Type hint for a move returned by an online source."""
354
+
355
+ # chessdb
356
+ uci: str
357
+ san: str
358
+ score: int
359
+ rank: int
360
+ note: str
361
+ winrate: str
362
+
363
+ # lichess explorer
364
+ # uci: str (duplicate)
365
+ # san: str (duplicate)
366
+ averageRating: int
367
+ performance: int
368
+ white: int
369
+ black: int
370
+ draws: int
371
+ game: LichessExplorerGameType | None
372
+
373
+ # lichess egtb
374
+ # uci: str (duplicate)
375
+ # san: str (duplicate)
376
+ zeroing: bool
377
+ checkmate: bool
378
+ stalemate: bool
379
+ variant_win: bool
380
+ variant_loss: bool
381
+ insufficient_material: bool
382
+ dtz: int
383
+ precise_dtz: int | None
384
+ dtm: int | None
385
+ dtc: int
386
+ category: str
387
+
388
+
389
+ class OnlineType(TypedDict, total=False):
390
+ """Type hint for moves returned by an online source."""
391
+
392
+ # lichess egtb
393
+ checkmate: bool
394
+ stalemate: bool
395
+ variant_win: bool
396
+ variant_loss: bool
397
+ insufficient_material: bool
398
+ dtz: int
399
+ precise_dtz: int | None
400
+ dtm: int | None
401
+ dtc: int
402
+ category: str
403
+ # moves: list[LichessEGTBMoveType]
404
+
405
+ # lichess explorer
406
+ white: int
407
+ black: int
408
+ draws: int
409
+ # moves: list[LichessExplorerMoveType]
410
+ topGames: list[LichessExplorerGameType]
411
+ recentGames: list[LichessExplorerGameType]
412
+ opening: dict[str, str] | None
413
+ queuePosition: int
414
+
415
+ # lichess cloud
416
+ fen: str
417
+ knodes: int
418
+ ply: str
419
+ depth: int
420
+ pvs: list[LichessPvType]
421
+ error: str
422
+
423
+ # chessdb (opening & egtb)
424
+ status: str
425
+ # moves: list[ChessDBMoveType]
426
+ # ply: str (duplicate)
427
+ score: int
428
+ # depth: int (duplicate)
429
+ pv: list[str]
430
+ pvSAN: list[str]
431
+ move: str
432
+ egtb: str
433
+
434
+ # all
435
+ moves: list[OnlineMoveType]
436
+
437
+
438
+ class TokenTestType(TypedDict, total=False):
439
+ """Type hint for token test."""
440
+
441
+ scopes: str
442
+ userId: str
443
+ expires: int
444
+
445
+
446
+ TOKEN_TESTS_TYPE: TypeAlias = dict[str, TokenTestType]
447
+
448
+
449
+ class _BackoffDetails(TypedDict):
450
+ """`_Details` from `backoff._typing`."""
451
+
452
+ target: Callable[..., Any]
453
+ args: tuple[Any, ...]
454
+ kwargs: dict[str, Any]
455
+ tries: int
456
+ elapsed: float
457
+
458
+
459
+ class BackoffDetails(_BackoffDetails, total=False):
460
+ """`Details` from `backoff._typing`."""
461
+
462
+ wait: float # present in the on_backoff handler case for either decorator
463
+ value: Any # present in the on_predicate decorator case
464
+
465
+
466
+ ENGINE_INPUT_ARGS_TYPE: TypeAlias = (OPTIONS_TYPE | type[BaseException] | BaseException | TracebackType | Board | Limit |
467
+ str | bool | None)
468
+ ENGINE_INPUT_KWARGS_TYPE: TypeAlias = int | bool | list[Move] | Opponent | None
lichess-bot-master/lib/matchmaking.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, days, seconds, minutes, years
8
+ from collections import defaultdict
9
+ from collections.abc import Sequence
10
+ from lib.lichess import Lichess, RateLimitedError
11
+ from lib.config import Configuration
12
+ from typing import cast, TypeAlias
13
+ from lib.blocklist import OnlineBlocklist
14
+ from lib.lichess_types import UserProfileType, PerfType, EventType, FilterType, ChallengeType
15
+ MULTIPROCESSING_LIST_TYPE: TypeAlias = Sequence[model.Challenge]
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class Matchmaking:
21
+ """Challenge other bots."""
22
+
23
+ def __init__(self, li: Lichess, config: Configuration, user_profile: UserProfileType) -> None:
24
+ """Initialize values needed for matchmaking."""
25
+ self.li = li
26
+ self.variants = list(filter(lambda variant: variant != "fromPosition", config.challenge.variants))
27
+ self.matchmaking_cfg = config.matchmaking
28
+ self.user_profile = user_profile
29
+ self.last_challenge_created_delay = Timer(seconds(25)) # Challenges expire after 20 seconds.
30
+ self.last_game_ended_delay = Timer(minutes(self.matchmaking_cfg.challenge_timeout))
31
+ self.last_user_profile_update_time = Timer(minutes(5))
32
+ self.min_wait_time = seconds(60) # Wait before new challenge to avoid api rate limits.
33
+ self.rate_limit_timer = Timer()
34
+
35
+ # Maximum time between challenges, even if there are active games
36
+ self.max_wait_time = minutes(10) if self.matchmaking_cfg.allow_during_games else years(10)
37
+ self.challenge_id = ""
38
+
39
+ # (opponent name, game aspect) --> other bot is likely to accept challenge
40
+ # game aspect is the one the challenged bot objects to and is one of:
41
+ # - game speed (bullet, blitz, etc.)
42
+ # - variant (standard, horde, etc.)
43
+ # - casual/rated
44
+ # - empty string (if no other reason is given or self.filter_type is COARSE)
45
+ self.challenge_type_acceptable: defaultdict[tuple[str, str], Timer] = defaultdict(Timer)
46
+ self.challenge_filter = self.matchmaking_cfg.challenge_filter
47
+
48
+ for name in self.matchmaking_cfg.block_list:
49
+ self.add_to_block_list(name)
50
+
51
+ self.online_block_list = OnlineBlocklist(self.matchmaking_cfg.online_block_list)
52
+
53
+ def should_create_challenge(self) -> bool:
54
+ """Whether we should create a challenge."""
55
+ matchmaking_enabled = self.matchmaking_cfg.allow_matchmaking
56
+ time_has_passed = self.last_game_ended_delay.is_expired() and self.rate_limit_timer.is_expired()
57
+ challenge_expired = self.last_challenge_created_delay.is_expired() and self.challenge_id
58
+ min_wait_time_passed = self.last_challenge_created_delay.time_since_reset() > self.min_wait_time
59
+ if challenge_expired:
60
+ self.li.cancel(self.challenge_id)
61
+ logger.info(f"Challenge id {self.challenge_id} cancelled.")
62
+ self.discard_challenge(self.challenge_id)
63
+ self.show_earliest_challenge_time()
64
+ return bool(matchmaking_enabled and (time_has_passed or challenge_expired) and min_wait_time_passed)
65
+
66
+ def create_challenge(self, username: str, base_time: int, increment: int, days: int, variant: str,
67
+ mode: str) -> str:
68
+ """Create a challenge."""
69
+ params: dict[str, str | int | bool] = {"rated": mode == "rated", "variant": variant}
70
+
71
+ if days:
72
+ params["days"] = days
73
+ elif base_time or increment:
74
+ params["clock.limit"] = base_time
75
+ params["clock.increment"] = increment
76
+ else:
77
+ logger.error("At least one of challenge_days, challenge_initial_time, or challenge_increment "
78
+ "must be greater than zero in the matchmaking section of your config file.")
79
+ return ""
80
+
81
+ try:
82
+ self.last_challenge_created_delay.reset()
83
+ response = self.li.challenge(username, params)
84
+ challenge_id = response.get("id", "")
85
+ if not challenge_id:
86
+ self.handle_challenge_error_response(response, username)
87
+ return challenge_id
88
+ except RateLimitedError as e:
89
+ logger.warning(e)
90
+ self.rate_limit_timer = Timer(e.timeout)
91
+ except Exception as e:
92
+ logger.debug(e, exc_info=e)
93
+
94
+ logger.warning("Could not create challenge")
95
+ self.show_earliest_challenge_time()
96
+ return ""
97
+
98
+ def handle_challenge_error_response(self, response: ChallengeType, username: str) -> None:
99
+ """If a challenge fails, print the error and adjust the challenge requirements in response."""
100
+ logger.error(response)
101
+ if response.get("bot_is_rate_limited"):
102
+ timeout = cast(datetime.timedelta, response.get("rate_limit_timeout"))
103
+ self.rate_limit_timer = Timer(timeout)
104
+ elif response.get("opponent_is_rate_limited"):
105
+ self.add_challenge_filter(username, "", response.get("rate_limit_timeout"))
106
+ else:
107
+ self.add_challenge_filter(username, "")
108
+ self.show_earliest_challenge_time()
109
+
110
+ def perf(self) -> dict[str, PerfType]:
111
+ """Get the bot's rating in every variant. Bullet, blitz, rapid etc. are considered different variants."""
112
+ user_perf: dict[str, PerfType] = self.user_profile["perfs"]
113
+ return user_perf
114
+
115
+ def username(self) -> str:
116
+ """Our username."""
117
+ username: str = self.user_profile["username"]
118
+ return username
119
+
120
+ def update_user_profile(self) -> None:
121
+ """Update our user profile data, to get our latest rating."""
122
+ if self.last_user_profile_update_time.is_expired():
123
+ self.last_user_profile_update_time.reset()
124
+ with contextlib.suppress(Exception):
125
+ self.user_profile = self.li.get_profile()
126
+
127
+ def get_weights(self, online_bots: list[UserProfileType], rating_preference: str, min_rating: int, max_rating: int,
128
+ game_type: str) -> list[int]:
129
+ """Get the weight for each bot. A higher weights means the bot is more likely to get challenged."""
130
+ def rating(bot: UserProfileType) -> int:
131
+ perfs: dict[str, PerfType] = bot.get("perfs", {})
132
+ perf: PerfType = perfs.get(game_type, {})
133
+ return perf.get("rating", 0)
134
+
135
+ if rating_preference == "high":
136
+ # A bot with max_rating rating will be twice as likely to get picked than a bot with min_rating rating.
137
+ reduce_ratings_by = min(min_rating - (max_rating - min_rating), min_rating - 1)
138
+ weights = [rating(bot) - reduce_ratings_by for bot in online_bots]
139
+ elif rating_preference == "low":
140
+ # A bot with min_rating rating will be twice as likely to get picked than a bot with max_rating rating.
141
+ reduce_ratings_by = max(max_rating - (min_rating - max_rating), max_rating + 1)
142
+ weights = [reduce_ratings_by - rating(bot) for bot in online_bots]
143
+ else:
144
+ weights = [1] * len(online_bots)
145
+ return weights
146
+
147
+ def choose_opponent(self) -> tuple[str | None, int, int, int, str, str]:
148
+ """Choose an opponent."""
149
+ override_choice = random.choice(self.matchmaking_cfg.overrides.keys() + [None])
150
+ logger.info(f"Using the {override_choice or 'default'} matchmaking configuration.")
151
+ override = {} if override_choice is None else self.matchmaking_cfg.overrides.lookup(override_choice)
152
+ match_config = self.matchmaking_cfg | override
153
+
154
+ variant = self.get_random_config_value(match_config, "challenge_variant", self.variants)
155
+ mode = self.get_random_config_value(match_config, "challenge_mode", ["casual", "rated"])
156
+ rating_preference = match_config.rating_preference
157
+
158
+ base_time = random.choice(match_config.challenge_initial_time)
159
+ increment = random.choice(match_config.challenge_increment)
160
+ num_days = random.choice(match_config.challenge_days)
161
+
162
+ play_correspondence = [bool(num_days), not bool(base_time or increment)]
163
+ if random.choice(play_correspondence):
164
+ base_time = 0
165
+ increment = 0
166
+ else:
167
+ num_days = 0
168
+
169
+ game_type = game_category(variant, base_time, increment, num_days)
170
+
171
+ min_rating = match_config.opponent_min_rating
172
+ max_rating = match_config.opponent_max_rating
173
+ rating_diff = match_config.opponent_rating_difference
174
+ bot_rating = self.perf().get(game_type, {}).get("rating", 0)
175
+ if rating_diff is not None and bot_rating > 0:
176
+ min_rating = bot_rating - rating_diff
177
+ max_rating = bot_rating + rating_diff
178
+ logger.info(f"Seeking {game_type} game with opponent rating in [{min_rating}, {max_rating}] ...")
179
+
180
+ def is_suitable_opponent(bot: UserProfileType) -> bool:
181
+ perf = bot.get("perfs", {}).get(game_type, {})
182
+ return (bot["username"] != self.username()
183
+ and not self.in_block_list(bot["username"])
184
+ and perf.get("games", 0) > 0
185
+ and min_rating <= perf.get("rating", 0) <= max_rating)
186
+
187
+ self.online_block_list.refresh()
188
+ online_bots = self.li.get_online_bots()
189
+ online_bots = list(filter(is_suitable_opponent, online_bots))
190
+
191
+ def ready_for_challenge(bot: UserProfileType) -> bool:
192
+ aspects = [variant, game_type, mode] if self.challenge_filter == FilterType.FINE else []
193
+ return all(self.should_accept_challenge(bot["username"], aspect) for aspect in aspects)
194
+
195
+ ready_bots = list(filter(ready_for_challenge, online_bots))
196
+ online_bots = ready_bots or online_bots
197
+ bot_username = None
198
+ weights = self.get_weights(online_bots, rating_preference, min_rating, max_rating, game_type)
199
+
200
+ try:
201
+ bot = random.choices(online_bots, weights=weights)[0]
202
+ bot_profile = self.li.get_public_data(bot["username"])
203
+ if bot_profile.get("blocking"):
204
+ self.add_to_block_list(bot["username"])
205
+ else:
206
+ bot_username = bot["username"]
207
+ except Exception:
208
+ if online_bots:
209
+ logger.exception("Error:")
210
+ else:
211
+ logger.error("No suitable bots found to challenge.")
212
+
213
+ return bot_username, base_time, increment, num_days, variant, mode
214
+
215
+ def get_random_config_value(self, config: Configuration, parameter: str, choices: list[str]) -> str:
216
+ """Choose a random value from `choices` if the parameter value in the config is `random`."""
217
+ value: str = config.lookup(parameter)
218
+ return value if value != "random" else random.choice(choices)
219
+
220
+ def challenge(self, active_games: set[str], challenge_queue: MULTIPROCESSING_LIST_TYPE, max_games: int) -> None:
221
+ """
222
+ Challenge an opponent.
223
+
224
+ :param active_games: The games that the bot is playing.
225
+ :param challenge_queue: The queue containing the challenges.
226
+ :param max_games: The maximum allowed number of simultaneous games.
227
+ """
228
+ max_games_for_matchmaking = max_games if self.matchmaking_cfg.allow_during_games else min(1, max_games)
229
+ game_count = len(active_games) + len(challenge_queue)
230
+ if (game_count >= max_games_for_matchmaking
231
+ or (game_count > 0 and self.last_challenge_created_delay.time_since_reset() < self.max_wait_time)
232
+ or not self.should_create_challenge()):
233
+ return
234
+
235
+ logger.info("Challenging a random bot")
236
+ self.update_user_profile()
237
+ bot_username, base_time, increment, days, variant, mode = self.choose_opponent()
238
+ logger.info(f"Will challenge {bot_username} for a {variant} game.")
239
+ challenge_id = self.create_challenge(bot_username, base_time, increment, days, variant, mode) if bot_username else ""
240
+ logger.info(f"Challenge id is {challenge_id or 'None'}.")
241
+ self.challenge_id = challenge_id
242
+
243
+ def discard_challenge(self, challenge_id: str) -> None:
244
+ """
245
+ Clear the ID of the most recent challenge if it is no longer needed.
246
+
247
+ :param challenge_id: The ID of the challenge that is expired, accepted, or declined.
248
+ """
249
+ if self.challenge_id == challenge_id:
250
+ self.challenge_id = ""
251
+
252
+ def game_done(self) -> None:
253
+ """Reset the timer for when the last game ended, and prints the earliest that the next challenge will be created."""
254
+ self.last_game_ended_delay.reset()
255
+ self.show_earliest_challenge_time()
256
+
257
+ def show_earliest_challenge_time(self) -> None:
258
+ """Show the earliest that the next challenge will be created."""
259
+ if self.matchmaking_cfg.allow_matchmaking:
260
+ postgame_timeout = self.last_game_ended_delay.time_until_expiration()
261
+ time_to_next_challenge = self.min_wait_time - self.last_challenge_created_delay.time_since_reset()
262
+ rate_limit_delay = self.rate_limit_timer.time_until_expiration()
263
+ time_left = max(postgame_timeout, time_to_next_challenge, rate_limit_delay)
264
+ earliest_challenge_time = datetime.datetime.now() + time_left
265
+ logger.info(f"Next challenge will be created after {earliest_challenge_time.strftime('%c')}")
266
+
267
+ def add_to_block_list(self, username: str) -> None:
268
+ """Add a bot to the blocklist."""
269
+ self.add_challenge_filter(username, "", years(10))
270
+
271
+ def in_block_list(self, username: str) -> bool:
272
+ """Check if an opponent is in the block list to prevent future challenges."""
273
+ return (not self.should_accept_challenge(username, "")) or username in self.online_block_list
274
+
275
+ def add_challenge_filter(self, username: str, game_aspect: str, timeout: datetime.timedelta | None = None) -> None:
276
+ """
277
+ Prevent creating another challenge for a timeout when an opponent has declined a challenge.
278
+
279
+ :param username: The name of the opponent.
280
+ :param game_aspect: The aspect of a game (time control, chess variant, etc.) that caused the opponent to decline a
281
+ challenge. If the parameter is empty, that is equivalent to adding the opponent to the block list.
282
+ :param timeout: The amount of time to not challenge an opponent. If None, the default is a day.
283
+ """
284
+ self.challenge_type_acceptable[(username, game_aspect)] = Timer(timeout or days(1))
285
+
286
+ def should_accept_challenge(self, username: str, game_aspect: str) -> bool:
287
+ """
288
+ Whether a bot is likely to accept a challenge to a game.
289
+
290
+ :param username: The name of the opponent.
291
+ :param game_aspect: A category of the challenge type (time control, chess variant, etc.) to test for acceptance.
292
+ If game_aspect is empty, this is equivalent to checking if the opponent is in the block list.
293
+ """
294
+ return self.challenge_type_acceptable[(username, game_aspect)].is_expired()
295
+
296
+ def accepted_challenge(self, event: EventType) -> None:
297
+ """
298
+ Set the challenge id to an empty string, if the challenge was accepted.
299
+
300
+ Otherwise, we would attempt to cancel the challenge later.
301
+ """
302
+ self.discard_challenge(event["game"]["id"])
303
+
304
+ def declined_challenge(self, event: EventType) -> None:
305
+ """
306
+ Handle a challenge that was declined by the opponent.
307
+
308
+ Depends on whether `FilterType` is `NONE`, `COARSE`, or `FINE`.
309
+ """
310
+ challenge = model.Challenge(event["challenge"], self.user_profile)
311
+ opponent = challenge.challenge_target
312
+ reason = event["challenge"]["declineReason"]
313
+ logger.info(f"{opponent} declined {challenge}: {reason}")
314
+ self.discard_challenge(challenge.id)
315
+ if not challenge.from_self or self.challenge_filter == FilterType.NONE:
316
+ return
317
+
318
+ mode = "rated" if challenge.rated else "casual"
319
+ decline_details: dict[str, str] = {"generic": "",
320
+ "later": "",
321
+ "nobot": "",
322
+ "toofast": challenge.speed,
323
+ "tooslow": challenge.speed,
324
+ "timecontrol": challenge.speed,
325
+ "rated": mode,
326
+ "casual": mode,
327
+ "standard": challenge.variant,
328
+ "variant": challenge.variant}
329
+
330
+ reason_key = event["challenge"]["declineReasonKey"].lower()
331
+ if reason_key not in decline_details:
332
+ logger.warning(f"Unknown decline reason received: {reason_key}")
333
+ game_problem = decline_details.get(reason_key, "") if self.challenge_filter == FilterType.FINE else ""
334
+ self.add_challenge_filter(opponent.name, game_problem)
335
+ logger.info(f"Will not challenge {opponent} to another {game_problem}".strip() + " game today.")
336
+
337
+ self.show_earliest_challenge_time()
338
+
339
+
340
+ def game_category(variant: str, base_time: int, increment: int, num_days: int) -> str:
341
+ """
342
+ Get the game type (e.g. bullet, atomic, classical). Lichess has one rating for every variant regardless of time control.
343
+
344
+ :param variant: The game's variant.
345
+ :param base_time: The base time in seconds.
346
+ :param increment: The increment in seconds.
347
+ :param num_days: If the game is correspondence, we have some days to play the move.
348
+ :return: The game category.
349
+ """
350
+ game_duration = base_time + increment * 40
351
+ if variant != "standard":
352
+ return variant
353
+ if num_days:
354
+ return "correspondence"
355
+ if game_duration < 179:
356
+ return "bullet"
357
+ if game_duration < 479:
358
+ return "blitz"
359
+ if game_duration < 1499:
360
+ return "rapid"
361
+ return "classical"
lichess-bot-master/lib/model.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import chess
7
+ from enum import Enum
8
+ from lib.blocklist import OnlineBlocklist
9
+ from lib.timer import Timer, msec, seconds, sec_str, to_msec, to_seconds, years
10
+ from lib.config import Configuration
11
+ from collections import defaultdict, Counter
12
+ from lib.lichess_types import UserProfileType, ChallengeType, GameEventType, PlayerType
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def is_chess_960(fen: str) -> bool:
18
+ """Determine whether an FEN string represents a chess960 game."""
19
+ return chess.Board(fen) != chess.Board(fen, chess960=True)
20
+
21
+
22
+ class Challenge:
23
+ """Store information about a challenge."""
24
+
25
+ def __init__(self, challenge_info: ChallengeType, user_profile: UserProfileType) -> None:
26
+ """:param user_profile: Information about our bot."""
27
+ self.id = challenge_info["id"]
28
+ self.rated = challenge_info["rated"]
29
+ self.variant = challenge_info["variant"]["key"]
30
+ self.perf_name = challenge_info["perf"]["name"]
31
+ self.speed = challenge_info["speed"]
32
+ self.increment = challenge_info.get("timeControl", {}).get("increment")
33
+ self.base = challenge_info.get("timeControl", {}).get("limit")
34
+ self.days = challenge_info.get("timeControl", {}).get("daysPerTurn")
35
+ self.challenger = Player(challenge_info.get("challenger") or {})
36
+ self.challenge_target = Player(challenge_info.get("destUser") or {})
37
+ self.from_self = self.challenger.name == user_profile["username"]
38
+ self.initial_fen = challenge_info.get("initialFen", "startpos")
39
+ color = challenge_info["color"]
40
+ self.color = color if color != "random" else challenge_info["finalColor"]
41
+ self.time_control = challenge_info["timeControl"]
42
+
43
+ def is_supported_variant(self, challenge_cfg: Configuration) -> bool:
44
+ """Check whether the variant is supported."""
45
+ if self.variant not in challenge_cfg.variants:
46
+ return False
47
+
48
+ if self.initial_fen == "startpos":
49
+ return True
50
+
51
+ if is_chess_960(self.initial_fen):
52
+ return "chess960" in challenge_cfg.variants
53
+
54
+ return True
55
+
56
+ def is_supported_time_control(self, challenge_cfg: Configuration) -> bool:
57
+ """Check whether the time control is supported."""
58
+ speeds = challenge_cfg.time_controls
59
+ increment_max: int = challenge_cfg.max_increment
60
+ increment_min: int = challenge_cfg.min_increment
61
+ base_max: int = challenge_cfg.max_base
62
+ base_min: int = challenge_cfg.min_base
63
+ days_max: float = challenge_cfg.max_days
64
+ days_min: float = challenge_cfg.min_days
65
+
66
+ if self.speed not in speeds:
67
+ return False
68
+
69
+ require_non_zero_increment = (self.challenger.is_bot
70
+ and self.speed == "bullet"
71
+ and challenge_cfg.bullet_requires_increment)
72
+ increment_min = max(increment_min, 1 if require_non_zero_increment else 0)
73
+
74
+ if self.base is not None and self.increment is not None:
75
+ # Normal clock game
76
+ return (increment_min <= self.increment <= increment_max
77
+ and base_min <= self.base <= base_max)
78
+ elif self.days is not None:
79
+ # Correspondence game
80
+ return days_min <= self.days <= days_max
81
+ else:
82
+ # Unlimited game
83
+ return days_max == math.inf
84
+
85
+ def is_supported_mode(self, challenge_cfg: Configuration) -> bool:
86
+ """Check whether the mode is supported."""
87
+ return ("rated" if self.rated else "casual") in challenge_cfg.modes
88
+
89
+ def is_supported_rating(self, challenge_cfg: Configuration, user_profile: UserProfileType) -> bool:
90
+ """Check whether the challenger's rating is within the acceptable range."""
91
+ challenger_rating = self.challenger.rating
92
+ if challenger_rating is None:
93
+ return True
94
+
95
+ min_rating: int = challenge_cfg.min_rating
96
+ max_rating: int = challenge_cfg.max_rating
97
+ rating_diff: int | None = challenge_cfg.rating_difference
98
+
99
+ if rating_diff is not None:
100
+ bot_rating = user_profile.get("perfs", {}).get(self.perf_name.lower(), {}).get("rating")
101
+ if bot_rating:
102
+ min_rating = max(min_rating, bot_rating - rating_diff)
103
+ max_rating = min(max_rating, bot_rating + rating_diff)
104
+
105
+ return min_rating <= challenger_rating <= max_rating
106
+
107
+ def is_supported_recent(self, config: Configuration, recent_bot_challenges: defaultdict[str, list[Timer]]) -> bool:
108
+ """Check whether we have played a lot of games with this opponent recently. Only used when the opponent is a BOT."""
109
+ # Filter out old challenges
110
+ recent_bot_challenges[self.challenger.name] = [timer for timer
111
+ in recent_bot_challenges[self.challenger.name]
112
+ if not timer.is_expired()]
113
+ max_recent_challenges = config.max_recent_bot_challenges
114
+ return (not self.challenger.is_bot
115
+ or max_recent_challenges is None
116
+ or len(recent_bot_challenges[self.challenger.name]) < max_recent_challenges)
117
+
118
+ def decline_due_to(self, requirement_met: bool, decline_reason: str) -> str:
119
+ """
120
+ Get the reason lichess-bot declined an incoming challenge.
121
+
122
+ :param requirement_met: Whether a requirement is met.
123
+ :param decline_reason: The reason we declined the challenge if the requirement wasn't met.
124
+ :return: `decline_reason` if `requirement_met` is false else returns an empty string.
125
+ """
126
+ return "" if requirement_met else decline_reason
127
+
128
+ def is_supported(self, config: Configuration, recent_bot_challenges: defaultdict[str, list[Timer]],
129
+ opponent_engagements: Counter[str], online_block_list: OnlineBlocklist,
130
+ user_profile: UserProfileType) -> tuple[bool, str]:
131
+ """Whether the challenge is supported."""
132
+ try:
133
+ if self.from_self:
134
+ return True, ""
135
+
136
+ from extra_game_handlers import is_supported_extra
137
+
138
+ allowed_opponents: list[str] = list(filter(None, config.allow_list)) or [self.challenger.name]
139
+ decline_reason = (self.decline_due_to(config.accept_bot or not self.challenger.is_bot, "noBot")
140
+ or self.decline_due_to(not config.only_bot or self.challenger.is_bot, "onlyBot")
141
+ or self.decline_due_to(self.is_supported_time_control(config), "timeControl")
142
+ or self.decline_due_to(self.is_supported_variant(config), "variant")
143
+ or self.decline_due_to(self.is_supported_mode(config), "casual" if self.rated else "rated")
144
+ or self.decline_due_to(self.is_supported_rating(config, user_profile), "generic")
145
+ or self.decline_due_to(self.challenger.name not in config.block_list, "generic")
146
+ or self.decline_due_to(self.challenger.name not in online_block_list, "generic")
147
+ or self.decline_due_to(self.challenger.name in allowed_opponents, "generic")
148
+ or self.decline_due_to(self.is_supported_recent(config, recent_bot_challenges), "later")
149
+ or self.decline_due_to(opponent_engagements[self.challenger.name]
150
+ < config.max_simultaneous_games_per_user, "later")
151
+ or self.decline_due_to(is_supported_extra(self), "generic"))
152
+
153
+ return not decline_reason, decline_reason
154
+
155
+ except Exception:
156
+ logger.exception(f"Error while checking challenge {self.id}:")
157
+ return False, "generic"
158
+
159
+ def score(self) -> int:
160
+ """Give a rating estimate to the opponent."""
161
+ rated_bonus = 200 if self.rated else 0
162
+ challenger_master_title = self.challenger.title if not self.challenger.is_bot else None
163
+ titled_bonus = 200 if challenger_master_title else 0
164
+ challenger_rating_int = self.challenger.rating or 0
165
+ return challenger_rating_int + rated_bonus + titled_bonus
166
+
167
+ def mode(self) -> str:
168
+ """Get the mode of the challenge (rated or casual)."""
169
+ return "rated" if self.rated else "casual"
170
+
171
+ def __str__(self) -> str:
172
+ """Get a string representation of `Challenge`."""
173
+ return f"{self.perf_name} {self.mode()} challenge from {self.challenger} ({self.id})"
174
+
175
+ def __repr__(self) -> str:
176
+ """Get a string representation of `Challenge`."""
177
+ return self.__str__()
178
+
179
+
180
+ class Termination(str, Enum):
181
+ """The possible game terminations."""
182
+
183
+ MATE = "mate"
184
+ TIMEOUT = "outoftime"
185
+ RESIGN = "resign"
186
+ ABORT = "aborted"
187
+ DRAW = "draw"
188
+
189
+
190
+ class Game:
191
+ """Store information about a game."""
192
+
193
+ def __init__(self, game_info: GameEventType, username: str, base_url: str, abort_time: datetime.timedelta) -> None:
194
+ """:param abort_time: How long to wait before aborting the game."""
195
+ self.username = username
196
+ self.id = game_info["id"]
197
+ self.speed = game_info.get("speed")
198
+ clock = game_info.get("clock") or {}
199
+ ten_years_in_ms = to_msec(years(10))
200
+ self.clock_initial = msec(clock.get("initial", ten_years_in_ms))
201
+ self.clock_increment = msec(clock.get("increment", 0))
202
+ self.perf_name = (game_info.get("perf") or {}).get("name", "{perf?}")
203
+ self.variant_name = game_info["variant"]["name"]
204
+ self.mode = "rated" if game_info.get("rated") else "casual"
205
+ self.white = Player(game_info["white"])
206
+ self.black = Player(game_info["black"])
207
+ self.initial_fen = game_info.get("initialFen")
208
+ self.state = game_info["state"]
209
+ self.is_white = (self.white.name or "").lower() == username.lower()
210
+ self.my_color = "white" if self.is_white else "black"
211
+ self.opponent_color = "black" if self.is_white else "white"
212
+ self.me = self.white if self.is_white else self.black
213
+ self.opponent = self.black if self.is_white else self.white
214
+ self.base_url = base_url
215
+ self.game_start = datetime.datetime.fromtimestamp(to_seconds(msec(game_info["createdAt"])),
216
+ tz=datetime.timezone.utc)
217
+ self.abort_time = Timer(abort_time)
218
+ self.terminate_time = Timer(self.clock_initial + self.clock_increment + abort_time + seconds(60))
219
+ self.disconnect_time = Timer(seconds(0))
220
+
221
+ def url(self) -> str:
222
+ """Get the url of the game."""
223
+ return f"{self.short_url()}/{self.my_color}"
224
+
225
+ def short_url(self) -> str:
226
+ """Get the short url of the game."""
227
+ return urljoin(self.base_url, self.id)
228
+
229
+ def pgn_event(self) -> str:
230
+ """Get the event to write in the PGN file."""
231
+ if self.variant_name in ["Standard", "From Position"]:
232
+ return f"{self.mode.title()} {self.perf_name.title()} game"
233
+ else:
234
+ return f"{self.mode.title()} {self.variant_name} game"
235
+
236
+ def time_control(self) -> str:
237
+ """Get the time control of the game."""
238
+ return f"{sec_str(self.clock_initial)}+{sec_str(self.clock_increment)}"
239
+
240
+ def is_abortable(self) -> bool:
241
+ """Whether the game can be aborted."""
242
+ # Moves are separated by spaces. A game is abortable when less
243
+ # than two moves (one from each player) have been played.
244
+ return " " not in self.state["moves"]
245
+
246
+ def ping(self, abort_in: datetime.timedelta, terminate_in: datetime.timedelta, disconnect_in: datetime.timedelta) -> None:
247
+ """
248
+ Tell the bot when to abort, terminate, and disconnect from a game.
249
+
250
+ :param abort_in: How many seconds to wait before aborting.
251
+ :param terminate_in: How many seconds to wait before terminating.
252
+ :param disconnect_in: How many seconds to wait before disconnecting.
253
+ """
254
+ if self.is_abortable():
255
+ self.abort_time = Timer(abort_in)
256
+ self.terminate_time = Timer(terminate_in)
257
+ self.disconnect_time = Timer(disconnect_in)
258
+
259
+ def should_abort_now(self) -> bool:
260
+ """Whether we should abort the game."""
261
+ return self.is_abortable() and self.abort_time.is_expired()
262
+
263
+ def should_terminate_now(self) -> bool:
264
+ """Whether we should terminate the game."""
265
+ return self.terminate_time.is_expired()
266
+
267
+ def should_disconnect_now(self) -> bool:
268
+ """Whether we should disconnect form the game."""
269
+ return self.disconnect_time.is_expired()
270
+
271
+ def my_remaining_time(self) -> datetime.timedelta:
272
+ """How many seconds we have left."""
273
+ wtime = msec(self.state["wtime"])
274
+ btime = msec(self.state["btime"])
275
+ return wtime if self.is_white else btime
276
+
277
+ def result(self) -> str:
278
+ """Get the result of the game."""
279
+ class GameEnding(str, Enum):
280
+ WHITE_WINS = "1-0"
281
+ BLACK_WINS = "0-1"
282
+ DRAW = "1/2-1/2"
283
+ INCOMPLETE = "*"
284
+
285
+ winner = self.state.get("winner")
286
+ termination = self.state.get("status")
287
+
288
+ if winner == "white":
289
+ result = GameEnding.WHITE_WINS
290
+ elif winner == "black":
291
+ result = GameEnding.BLACK_WINS
292
+ elif termination in [Termination.DRAW, Termination.TIMEOUT]:
293
+ result = GameEnding.DRAW
294
+ else:
295
+ result = GameEnding.INCOMPLETE
296
+
297
+ return result.value
298
+
299
+ def __str__(self) -> str:
300
+ """Get a string representation of `Game`."""
301
+ return f"{self.url()} {self.perf_name} vs {self.opponent} ({self.id})"
302
+
303
+ def __repr__(self) -> str:
304
+ """Get a string representation of `Game`."""
305
+ return self.__str__()
306
+
307
+
308
+ class Player:
309
+ """Store information about a player."""
310
+
311
+ def __init__(self, player_info: PlayerType) -> None:
312
+ """:param player_info: Contains information about a player."""
313
+ self.title = player_info.get("title")
314
+ self.rating = player_info.get("rating")
315
+ self.provisional = player_info.get("provisional")
316
+ self.aiLevel = player_info.get("aiLevel")
317
+ self.is_bot = self.title == "BOT" or self.aiLevel is not None
318
+ self.name = f"AI level {self.aiLevel}" if self.aiLevel else player_info.get("name", "")
319
+
320
+ def __str__(self) -> str:
321
+ """Get a string representation of `Player`."""
322
+ if self.aiLevel:
323
+ return self.name
324
+ rating = f'{self.rating}{"?" if self.provisional else ""}'
325
+ return f'{self.title or ""} {self.name} ({rating})'.strip()
326
+
327
+ def __repr__(self) -> str:
328
+ """Get a string representation of `Player`."""
329
+ return self.__str__()
lichess-bot-master/lib/timer.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A timer for use in lichess-bot."""
2
+
3
+ from datetime import timedelta
4
+ from time import perf_counter
5
+
6
+
7
+ def msec(time_in_msec: float) -> timedelta:
8
+ """Create a timedelta duration in milliseconds."""
9
+ return timedelta(milliseconds=time_in_msec)
10
+
11
+
12
+ def to_msec(duration: timedelta) -> float:
13
+ """Return a bare number representing the length of the duration in milliseconds."""
14
+ return duration / msec(1)
15
+
16
+
17
+ def msec_str(duration: timedelta) -> str:
18
+ """Return a string with the duration value in whole number milliseconds."""
19
+ return str(round(to_msec(duration)))
20
+
21
+
22
+ def seconds(time_in_sec: float) -> timedelta:
23
+ """Create a timedelta duration in seconds."""
24
+ return timedelta(seconds=time_in_sec)
25
+
26
+
27
+ def to_seconds(duration: timedelta) -> float:
28
+ """Return a bare number representing the length of the duration in seconds."""
29
+ return duration.total_seconds()
30
+
31
+
32
+ def sec_str(duration: timedelta) -> str:
33
+ """Return a string with the duration value in whole number seconds."""
34
+ return str(round(to_seconds(duration)))
35
+
36
+
37
+ def minutes(time_in_minutes: float) -> timedelta:
38
+ """Create a timedelta duration in minutes."""
39
+ return timedelta(minutes=time_in_minutes)
40
+
41
+
42
+ def hours(time_in_hours: float) -> timedelta:
43
+ """Create a timedelta duration in hours."""
44
+ return timedelta(hours=time_in_hours)
45
+
46
+
47
+ def days(time_in_days: float) -> timedelta:
48
+ """Create a timedelta duration in days."""
49
+ return timedelta(days=time_in_days)
50
+
51
+
52
+ def years(time_in_years: float) -> timedelta:
53
+ """Create a timedelta duration in median years--i.e., 365 days."""
54
+ return days(365) * time_in_years
55
+
56
+
57
+ zero_seconds = seconds(0)
58
+
59
+
60
+ class Timer:
61
+ """
62
+ A timer for use in lichess-bot. An instance of timer can be used both as a countdown timer and a stopwatch.
63
+
64
+ If the duration argument in the __init__() method is greater than zero, then
65
+ the method is_expired() indicates when the initial duration has passed. The
66
+ method time_until_expiration() gives the amount of time left until the timer
67
+ expires.
68
+
69
+ Regardless of the initial duration (even if it's zero), a timer can be used
70
+ as a stopwatch by calling time_since_reset() to get the amount of time since
71
+ the timer was created or since it was last reset.
72
+ """
73
+
74
+ def __init__(self, duration: timedelta = zero_seconds) -> None:
75
+ """
76
+ Start the timer.
77
+
78
+ :param duration: The duration of time before Timer.is_expired() returns True.
79
+ :param backdated_timestamp: When the timer should have started. Used to keep the timers between sessions.
80
+ """
81
+ self.duration = duration
82
+ self.starting_time = perf_counter()
83
+
84
+ def is_expired(self) -> bool:
85
+ """Check if a timer is expired."""
86
+ return self.time_since_reset() >= self.duration
87
+
88
+ def reset(self) -> None:
89
+ """Reset the timer."""
90
+ self.starting_time = perf_counter()
91
+
92
+ def time_since_reset(self) -> timedelta:
93
+ """How much time has passed."""
94
+ return seconds(perf_counter() - self.starting_time)
95
+
96
+ def time_until_expiration(self) -> timedelta:
97
+ """How much time is left until it expires."""
98
+ return max(seconds(0), self.duration - self.time_since_reset())
lichess-bot-master/lib/versioning.yml ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ lichess_bot_version: 2026.5.19.1
2
+ minimum_python_version: '3.10'
3
+ deprecated_python_version: '3.9'
4
+ deprecation_date: 2025-10-05
lichess-bot-master/lichess-bot.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Starting point for lichess-bot."""
2
+ from lib.lichess_bot import start_program
3
+
4
+ if __name__ == "__main__":
5
+ start_program()
lichess-bot-master/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ chess~=1.11
2
+ PyYAML~=6.0
3
+ requests~=2.34
4
+ backoff~=2.2
5
+ rich~=15.0
lichess-bot-master/test_bot/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """pytest won't search `test_bot/` if there is no `__init__.py` file."""
lichess-bot-master/test_bot/buggy_engine.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """An engine that takes too much time to make a move during tests."""
2
+
3
+ import chess
4
+ import time
5
+ import typing
6
+
7
+ if typing.TYPE_CHECKING:
8
+ from test_bot.test_games import scholars_mate
9
+ else:
10
+ from test_games import scholars_mate
11
+
12
+ assert input() == "uci"
13
+
14
+
15
+ def send_command(command: str) -> None:
16
+ """Send UCI commands to lichess-bot without output buffering."""
17
+ print(command, flush=True) # noqa: T201 (print() found)
18
+
19
+
20
+ send_command("id name Procrastinator")
21
+ send_command("id author lichess-bot-devs")
22
+ send_command("uciok")
23
+
24
+ delay_performed = False
25
+ just_started = True
26
+ board = chess.Board()
27
+ while True:
28
+ command, *remaining = input().split()
29
+ if command == "quit":
30
+ break
31
+ elif command == "isready":
32
+ send_command("readyok")
33
+ elif command == "position":
34
+ spec_type, *remaining = remaining
35
+ assert spec_type == "startpos"
36
+ board = chess.Board()
37
+ if remaining:
38
+ moves_label, *move_list = remaining
39
+ assert moves_label == "moves"
40
+ for move in move_list:
41
+ board.push_uci(move)
42
+ if just_started and len(board.move_stack) > 1:
43
+ delay_performed = True
44
+ elif command == "go":
45
+ move_count = len(board.move_stack)
46
+ if move_count == 3 and not delay_performed:
47
+ send_command("info string delaying move")
48
+ delay_performed = True
49
+ time.sleep(11)
50
+ move = scholars_mate[move_count]
51
+ send_command(f"bestmove {move}")
52
+ just_started = False
lichess-bot-master/test_bot/conftest.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
7
+
8
+ def pytest_sessionfinish(session: Session, exitstatus: int | ExitCode) -> None: # noqa: ARG001
9
+ """
10
+ Remove files created when testing lichess-bot.
11
+
12
+ The only exception is if running in a GitHub action, in which case we save the engines to the cache.
13
+ """
14
+ if os.path.exists("TEMP") and not os.getenv("GITHUB_ACTIONS"):
15
+ shutil.rmtree("TEMP")
lichess-bot-master/test_bot/homemade.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Homemade engine playing scholar's mate."""
2
+ from homemade import ExampleEngine
3
+ import chess
4
+ import chess.engine
5
+ from lib.config import Configuration
6
+ from lib import model
7
+ from lib.lichess_types import OPTIONS_GO_EGTB_TYPE, COMMANDS_TYPE, MOVE
8
+ from test_bot.test_games import scholars_mate
9
+
10
+ # ruff: noqa: ARG002
11
+
12
+ class ScholarsMate(ExampleEngine):
13
+ """A homemade engine that plays the scholar's mate."""
14
+
15
+ def __init__(self, commands: COMMANDS_TYPE, options: OPTIONS_GO_EGTB_TYPE, stderr: int | None,
16
+ draw_or_resign: Configuration, game: model.Game | None, debug: bool, **popen_args: str) -> None:
17
+ """Set up engine."""
18
+ super().__init__(commands, options, stderr, draw_or_resign, game, debug, **popen_args)
19
+
20
+ def search(self, board: chess.Board, time_limit: chess.engine.Limit, ponder: bool, draw_offered: bool,
21
+ root_moves: MOVE) -> chess.engine.PlayResult:
22
+ """Get the next scholar's mate move."""
23
+ move_number = len(board.move_stack)
24
+ move = board.parse_uci(scholars_mate[move_number])
25
+ return chess.engine.PlayResult(move, None)
lichess-bot-master/test_bot/lichess.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Imitate `lichess.py`. Used in tests."""
2
+ from __future__ import annotations
3
+ import time
4
+ import chess.engine
5
+ import json
6
+ import logging
7
+ import traceback
8
+ import datetime
9
+ from queue import Queue
10
+ from requests.models import Response
11
+ from collections.abc import Generator
12
+ from lib.lichess import Lichess as OriginalLichess
13
+ from lib.timer import to_msec
14
+ from lib.lichess_types import (UserProfileType, ChallengeType, REQUESTS_PAYLOAD_TYPE, GameType, OnlineType, PublicDataType,
15
+ BackoffDetails)
16
+
17
+ # ruff: noqa: ARG002
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def backoff_handler(details: BackoffDetails) -> None:
23
+ """Log exceptions inside functions with the backoff decorator."""
24
+ logger.debug("Backing off {wait:0.1f} seconds after {tries} tries "
25
+ "calling function {target} with args {args} and kwargs {kwargs}".format(**details))
26
+ logger.debug(f"Exception: {traceback.format_exc()}")
27
+
28
+
29
+ def is_final(error: Exception) -> bool:
30
+ """Mock error handler for tests when a function has a backup decorator."""
31
+ logger.debug(error)
32
+ return False
33
+
34
+
35
+ class GameStream(Response):
36
+ """Imitate lichess.org's GameStream. Used in tests."""
37
+
38
+ def __init__(self,
39
+ board_queue: Queue[chess.Board],
40
+ clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]]) -> None:
41
+ """
42
+ Capture the interprocess queues that will feed the gameStream with game information.
43
+
44
+ :param board_queue: Updated board positions from the lichess_org_simulator() function.
45
+ :param clock_queue: Updated game clock timings (white time, black time, and increment) from the
46
+ lichess_org_simulator() function.
47
+ """
48
+ self.board_queue = board_queue
49
+ self.clock_queue = clock_queue
50
+
51
+ def iter_lines(self, chunk_size: int | None = 512, decode_unicode: bool = False,
52
+ delimiter: str | bytes | None = None) -> Generator[bytes, None, None]:
53
+ """Send the game events to lichess-bot."""
54
+ yield json.dumps(
55
+ {"id": "zzzzzzzz",
56
+ "variant": {"key": "standard",
57
+ "name": "Standard",
58
+ "short": "Std"},
59
+ "clock": {"initial": 60000,
60
+ "increment": 2000},
61
+ "speed": "bullet",
62
+ "perf": {"name": "Bullet"},
63
+ "rated": True,
64
+ "createdAt": 1600000000000,
65
+ "white": {"id": "bo",
66
+ "name": "bo",
67
+ "title": "BOT",
68
+ "rating": 3000},
69
+ "black": {"id": "b",
70
+ "name": "b",
71
+ "title": "BOT",
72
+ "rating": 3000,
73
+ "provisional": True},
74
+ "initialFen": "startpos",
75
+ "type": "gameFull",
76
+ "state": {"type": "gameState",
77
+ "moves": "",
78
+ "wtime": 10000,
79
+ "btime": 10000,
80
+ "winc": 100,
81
+ "binc": 100,
82
+ "status": "started"}}).encode("utf-8")
83
+ while True:
84
+ board = self.board_queue.get()
85
+ self.board_queue.task_done()
86
+
87
+ wtime, btime, increment = self.clock_queue.get()
88
+ self.clock_queue.task_done()
89
+
90
+ new_game_state = {"type": "gameState",
91
+ "moves": " ".join(move.uci() for move in board.move_stack),
92
+ "wtime": int(to_msec(wtime)),
93
+ "btime": int(to_msec(btime)),
94
+ "winc": int(to_msec(increment)),
95
+ "binc": int(to_msec(increment))}
96
+
97
+ if board.is_game_over():
98
+ new_game_state["status"] = "outoftime"
99
+ new_game_state["winner"] = "black"
100
+ yield json.dumps(new_game_state).encode("utf-8")
101
+ break
102
+
103
+ if board.move_stack:
104
+ new_game_state["status"] = "started"
105
+ yield json.dumps(new_game_state).encode("utf-8")
106
+
107
+ def __enter__(self) -> GameStream: # noqa: PYI034
108
+ """Enter game stream context."""
109
+ return self
110
+
111
+ def __exit__(self, *args: object) -> None:
112
+ """Exit game stream context."""
113
+
114
+
115
+ class EventStream(Response):
116
+ """Imitate lichess.org's EventStream. Used in tests."""
117
+
118
+ def __init__(self, sent_game: bool = False) -> None:
119
+ """
120
+ Start the event stream for the lichess_bot_main() loop.
121
+
122
+ :param sent_game: If we have already sent the `gameStart` event, so we don't send it again.
123
+ """
124
+ self.sent_game = sent_game
125
+
126
+ def iter_lines(self, chunk_size: int | None = 512, decode_unicode: bool = False,
127
+ delimiter: str | bytes | None = None) -> Generator[bytes, None, None]:
128
+ """Send the events to lichess-bot."""
129
+ if self.sent_game:
130
+ yield b""
131
+ time.sleep(1)
132
+ else:
133
+ yield json.dumps(
134
+ {"type": "gameStart",
135
+ "game": {"id": "zzzzzzzz",
136
+ "source": "friend",
137
+ "compat": {"bot": True,
138
+ "board": True}}}).encode("utf-8")
139
+
140
+ def __enter__(self) -> EventStream: # noqa: PYI034
141
+ """Enter context block."""
142
+ return self
143
+
144
+ def __exit__(self, *args: object) -> None:
145
+ """Exit context block."""
146
+
147
+
148
+ # Docs: https://lichess.org/api.
149
+ class Lichess(OriginalLichess):
150
+ """Imitate communication with lichess.org."""
151
+
152
+ def __init__(self,
153
+ move_queue: Queue[chess.Move | None],
154
+ board_queue: Queue[chess.Board],
155
+ clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]]) -> None:
156
+ """
157
+ Capture the interprocess queues to distribute them to the eventStream and gameStream instances.
158
+
159
+ :param move_queue: An interprocess queue to send moves chosen by the bot under test to the mock lichess function.
160
+ :param board_queue: An interprocess queue to send board positions to the mock game stream.
161
+ :param clock_queue: An interprocess queue to send game clock information to the mock game stream.
162
+ """
163
+ self.baseUrl = "testing"
164
+ self.move_queue = move_queue
165
+ self.board_queue = board_queue
166
+ self.clock_queue = clock_queue
167
+ self.sent_game = False
168
+ self.started_game_stream = False
169
+
170
+ def upgrade_to_bot_account(self) -> None:
171
+ """Isn't used in tests."""
172
+
173
+ def make_move(self, game_id: str, move: chess.engine.PlayResult) -> None:
174
+ """Send a move to the opponent engine thread."""
175
+ self.move_queue.put(move.move)
176
+
177
+ def accept_takeback(self, game_id: str, accept: bool) -> bool:
178
+ """Isn't used in tests."""
179
+ return False
180
+
181
+ def chat(self, game_id: str, room: str, text: str) -> None:
182
+ """Isn't used in tests."""
183
+
184
+ def abort(self, game_id: str) -> None:
185
+ """Isn't used in tests."""
186
+
187
+ def get_event_stream(self) -> EventStream:
188
+ """Send the `EventStream`."""
189
+ events = EventStream(self.sent_game)
190
+ self.sent_game = True
191
+ return events
192
+
193
+ def get_game_stream(self, game_id: str) -> GameStream:
194
+ """Send the `GameStream`."""
195
+ if self.started_game_stream:
196
+ self.move_queue.put(None)
197
+ self.started_game_stream = True
198
+ return GameStream(self.board_queue, self.clock_queue)
199
+
200
+ def accept_challenge(self, challenge_id: str) -> None:
201
+ """Isn't used in tests."""
202
+
203
+ def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None:
204
+ """Isn't used in tests."""
205
+
206
+ def get_profile(self) -> UserProfileType:
207
+ """Return a simple profile for the bot that lichess-bot uses when testing."""
208
+ return {"id": "b",
209
+ "username": "b",
210
+ "online": True,
211
+ "title": "BOT",
212
+ "url": "https://lichess.org/@/b",
213
+ "followable": True,
214
+ "following": False,
215
+ "blocking": False,
216
+ "followsYou": False,
217
+ "perfs": {}}
218
+
219
+ def get_ongoing_games(self) -> list[GameType]:
220
+ """Return that the bot isn't playing a game."""
221
+ return []
222
+
223
+ def resign(self, game_id: str) -> None:
224
+ """Isn't used in tests."""
225
+
226
+ def get_game_pgn(self, game_id: str) -> str:
227
+ """Return a simple PGN."""
228
+ return """
229
+ [Event "Test game"]
230
+ [Site "pytest"]
231
+ [Date "2022.03.11"]
232
+ [Round "1"]
233
+ [White "bo"]
234
+ [Black "b"]
235
+ [Result "0-1"]
236
+
237
+ *
238
+ """
239
+
240
+ def get_online_bots(self) -> list[UserProfileType]:
241
+ """Return that the only bot online is us."""
242
+ return [{"username": "b", "online": True}]
243
+
244
+ def challenge(self, username: str, payload: REQUESTS_PAYLOAD_TYPE) -> ChallengeType:
245
+ """Isn't used in tests."""
246
+ return {}
247
+
248
+ def cancel(self, challenge_id: str) -> None:
249
+ """Isn't used in tests."""
250
+
251
+ def online_book_get(self, path: str, params: dict[str, str | int] | None = None, *,
252
+ stream: bool = False, authenticated: bool = False) -> OnlineType:
253
+ """Isn't used in tests."""
254
+ return {}
255
+
256
+ def is_online(self, user_id: str) -> bool:
257
+ """Return that a bot is online."""
258
+ return True
259
+
260
+ def get_public_data(self, user_name: str) -> PublicDataType:
261
+ """Isn't used in tests."""
262
+ return {}
lichess-bot-master/test_bot/ruff.toml ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ target-version = "py310"
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
+ "DTZ005", # datetime without timezone
16
+ "EM101", # Exception must not use a string literal, assign to variable first
17
+ "EM102", # Exception must not use an f-string literal, assign to variable first
18
+ "ERA001", # Found commented-out code
19
+ "FBT", # Boolean argument in function definition
20
+ "G", # Logging
21
+ "I001", # Import block is un-sorted or un-formatted
22
+ "N803", # Argument name should be lowercase
23
+ "N806", # Variable in function should be lowercase
24
+ "PERF203", # try-except within a loop incurs performance overhead
25
+ "PLC0415", # `import` should be at the top-level of a file
26
+ "PLR0913", # Too many arguments in function definition
27
+ "PLR0915", # Too many statements
28
+ "PLR2004", # Magic value used in comparison, consider replacing `20` with a constant variable
29
+ "PLW0603", # Using the global statement to update variable is discouraged
30
+ "PT018", # Assertion should be broken down into multiple parts
31
+ "PTH", # Replace builtin functions with Path methods
32
+ "RET505", # Unnecessary else after return statement
33
+ "RUF005", # Consider [*list1, None] instead of concatenation (list1 + [None])
34
+ "RUF021", # Parenthesize `a and b` expressions when chaining `and` and `or` together, to make the precedence clear
35
+ "S101", # Use of assert detected
36
+ "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
37
+ "SIM108", # Use ternary operator instead of `if`-`else`-block
38
+ "TC001", # Move application import into a type-checking block
39
+ "TC002", # Move third-party import into a type-checking block
40
+ "TC003", # Move standard library import into a type-checking block
41
+ "TC006", # Add quotes to type expression in `typing.cast()`
42
+ "TRY", # Try-except suggestions
43
+ ]
44
+
45
+ [lint.mccabe]
46
+ max-complexity = 10
lichess-bot-master/test_bot/test-requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ pytest~=9.0
2
+ pytest-timeout~=2.4
3
+ ruff~=0.15
4
+ mypy~=2.1
5
+ types-requests~=2.33
6
+ types-PyYAML~=6.0
lichess-bot-master/test_bot/test_blocklist.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the blocklist module."""
2
+ from unittest.mock import Mock, patch
3
+
4
+ import pytest
5
+ from urllib3.exceptions import HTTPError
6
+
7
+ from lib.blocklist import BlocklistData, _parse_block_list_from_url, OnlineBlocklist
8
+
9
+
10
+ def test_parse_block_list_from_url_success() -> None:
11
+ """Test parsing blocklist from URL with successful response."""
12
+ mock_response = Mock()
13
+ mock_response.status_code = 200
14
+ mock_response.text = "user1\nuser2\nuser3"
15
+ mock_response.headers = {"ETag": "test-etag-123"}
16
+ mock_response.raise_for_status = Mock()
17
+
18
+ old_data = BlocklistData([], None)
19
+
20
+ with patch("lib.blocklist.requests.get", return_value=mock_response):
21
+ result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
22
+
23
+ assert result.users == ["user1", "user2", "user3"]
24
+ assert result.etag == "test-etag-123"
25
+
26
+
27
+ def test_parse_block_list_from_url__several_line_breaks() -> None:
28
+ """Test parsing blocklist from URL with several line-breaks."""
29
+ mock_response = Mock()
30
+ mock_response.status_code = 200
31
+ mock_response.headers = {"ETag": "test-etag-123"}
32
+ mock_response.raise_for_status = Mock()
33
+
34
+ old_data = BlocklistData([], None)
35
+
36
+ texts = [
37
+ "user1\nuser2\nuser3",
38
+ "user1\r\nuser2\r\n\r\nuser3",
39
+ "user1\ruser2\r\ruser3",
40
+ "user1\nuser2\nuser3",
41
+ ]
42
+ for text in texts:
43
+ mock_response.text = text
44
+
45
+ with patch("lib.blocklist.requests.get", return_value=mock_response):
46
+ result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
47
+
48
+ assert result.users == ["user1", "user2", "user3"]
49
+ assert result.etag == "test-etag-123"
50
+
51
+
52
+ def test_parse_block_list_from_url_not_modified() -> None:
53
+ """Test parsing blocklist returns cached data when not modified (304)."""
54
+ mock_response = Mock()
55
+ mock_response.status_code = 304
56
+ mock_response.raise_for_status = Mock()
57
+
58
+ old_data = BlocklistData(["cached_user1", "cached_user2"], "old-etag")
59
+
60
+ with patch("lib.blocklist.requests.get", return_value=mock_response):
61
+ result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
62
+
63
+ assert result.users == ["cached_user1", "cached_user2"]
64
+ assert result.etag == "old-etag"
65
+
66
+
67
+ def test_parse_block_list_from_url_with_etag_header() -> None:
68
+ """Test that ETag is sent in request headers when available."""
69
+ mock_response = Mock()
70
+ mock_response.status_code = 200
71
+ mock_response.text = "user1"
72
+ mock_response.headers = {}
73
+ mock_response.raise_for_status = Mock()
74
+
75
+ old_data = BlocklistData([], "existing-etag")
76
+
77
+ with patch("lib.blocklist.requests.get", return_value=mock_response) as mock_get:
78
+ _parse_block_list_from_url("http://example.com/blocklist", old_data)
79
+ mock_get.assert_called_once_with(
80
+ "http://example.com/blocklist",
81
+ headers={"If-None-Match": "existing-etag"},
82
+ timeout=15,
83
+ )
84
+
85
+
86
+ def test_parse_block_list_from_url_without_etag_header() -> None:
87
+ """Test that no ETag header is sent when old_data has no ETag."""
88
+ mock_response = Mock()
89
+ mock_response.status_code = 200
90
+ mock_response.text = "user1"
91
+ mock_response.headers = {}
92
+ mock_response.raise_for_status = Mock()
93
+
94
+ old_data = BlocklistData([], None)
95
+
96
+ with patch("lib.blocklist.requests.get", return_value=mock_response) as mock_get:
97
+ _parse_block_list_from_url("http://example.com/blocklist", old_data)
98
+ mock_get.assert_called_once_with(
99
+ "http://example.com/blocklist",
100
+ headers={},
101
+ timeout=15
102
+ )
103
+
104
+
105
+ def test_parse_block_list_from_url_strips_whitespace() -> None:
106
+ """Test that usernames are stripped of leading/trailing whitespace."""
107
+ mock_response = Mock()
108
+ mock_response.status_code = 200
109
+ mock_response.text = " user1 \n user2\t\nuser3 "
110
+ mock_response.headers = {}
111
+ mock_response.raise_for_status = Mock()
112
+
113
+ old_data = BlocklistData([], None)
114
+
115
+ with patch("lib.blocklist.requests.get", return_value=mock_response):
116
+ result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
117
+
118
+ assert result.users == ["user1", "user2", "user3"]
119
+
120
+
121
+ def test_parse_block_list_from_url_empty_response() -> None:
122
+ """Test parsing blocklist with empty response."""
123
+ mock_response = Mock()
124
+ mock_response.status_code = 200
125
+ mock_response.text = ""
126
+ mock_response.headers = {}
127
+ mock_response.raise_for_status = Mock()
128
+
129
+ old_data = BlocklistData([], None)
130
+
131
+ with patch("lib.blocklist.requests.get", return_value=mock_response):
132
+ result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
133
+
134
+ assert result.users == []
135
+
136
+
137
+ def test_parse_block_list_from_url_no_etag_in_response() -> None:
138
+ """Test parsing blocklist when response has no ETag header."""
139
+ mock_response = Mock()
140
+ mock_response.status_code = 200
141
+ mock_response.text = "user1\nuser2"
142
+ mock_response.headers = {}
143
+ mock_response.raise_for_status = Mock()
144
+
145
+ old_data = BlocklistData([], None)
146
+
147
+ with patch("lib.blocklist.requests.get", return_value=mock_response):
148
+ result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
149
+
150
+ assert result.users == ["user1", "user2"]
151
+ assert result.etag is None
152
+
153
+
154
+ def test_parse_block_list_from_url_multiline_with_blanks() -> None:
155
+ """Test parsing blocklist that contains blank lines."""
156
+ mock_response = Mock()
157
+ mock_response.status_code = 200
158
+ mock_response.text = "user1\n\nuser2\n\n\nuser3"
159
+ mock_response.headers = {}
160
+ mock_response.raise_for_status = Mock()
161
+
162
+ old_data = BlocklistData([], None)
163
+
164
+ with patch("lib.blocklist.requests.get", return_value=mock_response):
165
+ result = _parse_block_list_from_url("http://example.com/blocklist", old_data)
166
+
167
+ assert result.users == ["user1", "user2", "user3"]
168
+
169
+
170
+ def test_parse_block_list_from_url__exception() -> None:
171
+ """Test parsing blocklist from URL with exception."""
172
+ mock_response = Mock()
173
+ mock_response.status_code = 500
174
+ mock_response.raise_for_status.side_effect = HTTPError("500 Internal Server Error")
175
+
176
+ old_data = BlocklistData([], None)
177
+
178
+ with patch("lib.blocklist.requests.get", return_value=mock_response), pytest.raises(HTTPError):
179
+ _parse_block_list_from_url("http://example.com/blocklist", old_data)
180
+
181
+
182
+ def test_online_blocklist_refresh_success() -> None:
183
+ """Test successful refresh of all blocklists."""
184
+ mock_response1 = Mock()
185
+ mock_response1.status_code = 200
186
+ mock_response1.text = "user1\nuser2"
187
+ mock_response1.headers = {"ETag": "etag1"}
188
+ mock_response1.raise_for_status = Mock()
189
+
190
+ mock_response2 = Mock()
191
+ mock_response2.status_code = 200
192
+ mock_response2.text = "user3\nuser4\nuser5"
193
+ mock_response2.headers = {"ETag": "etag2"}
194
+ mock_response2.raise_for_status = Mock()
195
+
196
+ with patch("lib.blocklist.requests.get") as mock_get:
197
+ mock_get.side_effect = [mock_response1, mock_response2]
198
+ blocklist = OnlineBlocklist(["http://example.com/list1", "http://example.com/list2"])
199
+
200
+ assert blocklist.blocklist["http://example.com/list1"].users == ["user1", "user2"]
201
+ assert blocklist.blocklist["http://example.com/list1"].etag == "etag1"
202
+ assert blocklist.blocklist["http://example.com/list2"].users == ["user3", "user4", "user5"]
203
+ assert blocklist.blocklist["http://example.com/list2"].etag == "etag2"
204
+
205
+
206
+ def test_online_blocklist_refresh_partial_failure() -> None:
207
+ """Test refresh when some blocklists fail to load."""
208
+ mock_response_success = Mock()
209
+ mock_response_success.status_code = 200
210
+ mock_response_success.text = "user1\nuser2"
211
+ mock_response_success.headers = {"ETag": "etag1"}
212
+ mock_response_success.raise_for_status = Mock()
213
+
214
+ mock_response_fail = Mock()
215
+ mock_response_fail.raise_for_status.side_effect = HTTPError("500 Server Error")
216
+
217
+ with patch("lib.blocklist.requests.get") as mock_get:
218
+ # First call succeeds, second call fails
219
+ mock_get.side_effect = [mock_response_success, mock_response_fail]
220
+ blocklist = OnlineBlocklist(["http://example.com/list1", "http://example.com/list2"])
221
+
222
+ # First blocklist should be loaded
223
+ assert blocklist.blocklist["http://example.com/list1"].users == ["user1", "user2"]
224
+
225
+ # Second blocklist should remain empty (failed to load)
226
+ assert blocklist.blocklist["http://example.com/list2"].users == []
227
+ assert blocklist.blocklist["http://example.com/list2"].etag is None
lichess-bot-master/test_bot/test_bot.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test lichess-bot."""
2
+ import yaml
3
+ import chess
4
+ import chess.engine
5
+ import threading
6
+ import os
7
+ import sys
8
+ import datetime
9
+ import time
10
+ import logging
11
+ import tempfile
12
+ from multiprocessing import Manager
13
+ from queue import Queue
14
+ import test_bot.lichess
15
+ from lib import config
16
+ from lib.timer import Timer, seconds
17
+ from lib.engine_wrapper import test_suffix
18
+ from lib.lichess_types import CONFIG_DICT_TYPE
19
+ if "pytest" not in sys.modules:
20
+ sys.exit(f"The script {os.path.basename(__file__)} should only be run by pytest.")
21
+ from lib import lichess_bot
22
+ from test_bot.test_games import scholars_mate
23
+
24
+
25
+ logging_level = logging.DEBUG
26
+ testing_log_file_name = None
27
+ lichess_bot.logging_configurer(logging_level, testing_log_file_name, True)
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def lichess_org_simulator(move_queue: Queue[chess.Move | None],
32
+ board_queue: Queue[chess.Board],
33
+ clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]],
34
+ results: Queue[bool]) -> None:
35
+ """
36
+ Run a mocked version of the lichess.org server to provide an opponent for a test. This opponent always plays white.
37
+
38
+ :param opponent_path: The path to the executable of the opponent. Usually Stockfish.
39
+ :param move_queue: An interprocess queue that supplies the moves chosen by the bot being tested.
40
+ :param board_queue: An interprocess queue where this function sends the updated board after choosing a move.
41
+ :param clock_queue: An interprocess queue where this function sends the updated game clock after choosing a move.
42
+ :param results: An interprocess queue where this function sends the result of the game to the testing function.
43
+ """
44
+ start_time = seconds(10)
45
+ increment = seconds(0.1)
46
+
47
+ board = chess.Board()
48
+ wtime = start_time
49
+ btime = start_time
50
+
51
+ while not board.is_game_over():
52
+ move_timer = Timer()
53
+ if board.turn == chess.WHITE:
54
+ move_count = len(board.move_stack)
55
+ engine_move = board.parse_uci(scholars_mate[move_count])
56
+ board.push(engine_move)
57
+ board_queue.put(board)
58
+ clock_queue.put((wtime, btime, increment))
59
+ if len(board.move_stack) > 1:
60
+ wtime -= move_timer.time_since_reset()
61
+ wtime += increment
62
+ else:
63
+ move_timer = Timer()
64
+ while (bot_move := move_queue.get()) is None:
65
+ board_queue.put(board)
66
+ clock_queue.put((wtime, btime, increment))
67
+ move_queue.task_done()
68
+ board.push(bot_move)
69
+ move_queue.task_done()
70
+ if len(board.move_stack) > 2:
71
+ btime -= move_timer.time_since_reset()
72
+ btime += increment
73
+
74
+ board_queue.put(board)
75
+ clock_queue.put((wtime, btime, increment))
76
+ outcome = board.outcome()
77
+ results.put(outcome is not None and outcome.winner == chess.BLACK)
78
+
79
+
80
+ def run_bot(raw_config: CONFIG_DICT_TYPE, logging_level: int) -> bool:
81
+ """
82
+ Start lichess-bot test with a mocked version of the lichess.org site.
83
+
84
+ :param raw_config: A dictionary of values to specify the engine to test. This engine will play as white.
85
+ :param logging_level: The level of logging to use during the test. Usually logging.DEBUG.
86
+ :param opponent_path: The path to the executable that will play the opponent. The opponent plays as black.
87
+ """
88
+ config.insert_default_values(raw_config)
89
+ CONFIG = config.Configuration(raw_config)
90
+ logger.info(lichess_bot.intro())
91
+ manager = Manager()
92
+ board_queue: Queue[chess.Board] = manager.Queue()
93
+ clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]] = manager.Queue()
94
+ move_queue: Queue[chess.Move | None] = manager.Queue()
95
+ li = test_bot.lichess.Lichess(move_queue, board_queue, clock_queue)
96
+
97
+ user_profile = li.get_profile()
98
+ username = user_profile["username"]
99
+ if user_profile.get("title") != "BOT":
100
+ return False
101
+ logger.info(f"Welcome {username}!")
102
+ lichess_bot.disable_restart()
103
+
104
+ results: Queue[bool] = manager.Queue()
105
+ thr = threading.Thread(target=lichess_org_simulator, args=[move_queue, board_queue, clock_queue, results])
106
+ thr.start()
107
+ lichess_bot.start(li, user_profile, CONFIG, logging_level, testing_log_file_name, True, one_game=True)
108
+
109
+ result = results.get()
110
+ results.task_done()
111
+
112
+ results.join()
113
+ board_queue.join()
114
+ clock_queue.join()
115
+ move_queue.join()
116
+
117
+ thr.join()
118
+
119
+ return result
120
+
121
+
122
+ def test_uci() -> None:
123
+ """Test lichess-bot with Stockfish (UCI)."""
124
+ with open("./config.yml.default") as file:
125
+ CONFIG = yaml.safe_load(file)
126
+
127
+ with tempfile.TemporaryDirectory() as temp:
128
+ CONFIG["token"] = ""
129
+ CONFIG["engine"]["dir"] = "test_bot"
130
+ CONFIG["engine"]["name"] = "uci_engine.py"
131
+ CONFIG["engine"]["interpreter"] = sys.executable
132
+ CONFIG["pgn_directory"] = os.path.join(temp, "uci_game_record")
133
+ CONFIG["engine"]["uci_options"] = {}
134
+ win = run_bot(CONFIG, logging_level)
135
+ logger.info("Finished Testing UCI")
136
+ assert win
137
+ time.sleep(0.1) # Wait for file to be written.
138
+ assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
139
+ "bo vs b - zzzzzzzz.pgn"))
140
+
141
+
142
+ def test_xboard() -> None:
143
+ """Test lichess-bot with an XBoard engine."""
144
+ with open("./config.yml.default") as file:
145
+ CONFIG = yaml.safe_load(file)
146
+
147
+ with tempfile.TemporaryDirectory() as temp:
148
+ CONFIG["token"] = ""
149
+ CONFIG["engine"]["dir"] = "test_bot"
150
+ CONFIG["engine"]["name"] = "xboard_engine.py"
151
+ CONFIG["engine"]["protocol"] = "xboard"
152
+ CONFIG["engine"]["interpreter"] = sys.executable
153
+ CONFIG["pgn_directory"] = os.path.join(temp, "lc0_game_record")
154
+ win = run_bot(CONFIG, logging_level)
155
+ logger.info("Finished Testing XBoard")
156
+ assert win
157
+ time.sleep(0.1) # Wait for file to be written.
158
+ assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
159
+ "bo vs b - zzzzzzzz.pgn"))
160
+
161
+
162
+ def test_homemade() -> None:
163
+ """Test lichess-bot with a homemade engine."""
164
+ with open("./config.yml.default") as file:
165
+ CONFIG = yaml.safe_load(file)
166
+
167
+ with tempfile.TemporaryDirectory() as temp:
168
+ CONFIG["token"] = ""
169
+ CONFIG["engine"]["name"] = f"ScholarsMate{test_suffix}"
170
+ CONFIG["engine"]["protocol"] = "homemade"
171
+ CONFIG["pgn_directory"] = os.path.join(temp, "homemade_game_record")
172
+ win = run_bot(CONFIG, logging_level)
173
+ logger.info("Finished Testing Homemade")
174
+ assert win
175
+ time.sleep(0.1) # Wait for file to be written.
176
+ assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
177
+ "bo vs b - zzzzzzzz.pgn"))
178
+
179
+
180
+ def test_buggy_engine() -> None:
181
+ """Test lichess-bot with an engine that causes a timeout error within python-chess."""
182
+ with open("./config.yml.default") as file:
183
+ CONFIG = yaml.safe_load(file)
184
+
185
+ with tempfile.TemporaryDirectory() as temp:
186
+ CONFIG["token"] = ""
187
+ CONFIG["engine"]["dir"] = "test_bot"
188
+ CONFIG["engine"]["name"] = "buggy_engine.py"
189
+ CONFIG["engine"]["interpreter"] = sys.executable
190
+ CONFIG["engine"]["uci_options"] = {"go_commands": {"movetime": 100}}
191
+ CONFIG["pgn_directory"] = os.path.join(temp, "bug_game_record")
192
+ win = run_bot(CONFIG, logging_level)
193
+ logger.info("Finished Testing Buggy Engine")
194
+ assert win
195
+ time.sleep(0.1) # Wait for file to be written.
196
+ assert os.path.isfile(os.path.join(CONFIG["pgn_directory"],
197
+ "bo vs b - zzzzzzzz.pgn"))
lichess-bot-master/test_bot/test_config.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test functions for config module."""
2
+ import logging
3
+
4
+ import pytest
5
+
6
+ from lib import config
7
+
8
+
9
+ def test_config_assert__false() -> None:
10
+ """Test that config_assert raises an exception with the provided error message."""
11
+ with pytest.raises(Exception, match="some error"):
12
+ config.config_assert(False, "some error")
13
+
14
+
15
+ def test_config_assert__true() -> None:
16
+ """Test that config_assert does not raise when assertion is True."""
17
+ config.config_assert(True, "no error")
18
+
19
+
20
+ def test_config_warn__true(caplog: pytest.LogCaptureFixture) -> None:
21
+ """Test that config_warn does not log a warning when assertion is True."""
22
+ with caplog.at_level(logging.WARNING):
23
+ config.config_warn(True, "this should not appear")
24
+ assert len(caplog.records) == 0 # No warning should be logged
25
+
26
+
27
+ def test_config_warn__false(caplog: pytest.LogCaptureFixture) -> None:
28
+ """Test that config_warn logs a warning when assertion is False."""
29
+ with caplog.at_level(logging.WARNING):
30
+ config.config_warn(False, "test warning message")
31
+ assert "test warning message" in caplog.text
32
+ assert len(caplog.records) == 1
33
+ assert caplog.records[0].levelname == "WARNING"