mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-07-04 16:03:07 +02:00
- Refactor loops to use explicit next()/StopIteration for coverage - Add tests for _collect_analysis_lines with empty and full iterators - Add tests for _process_game_events_loop with multiple game events - Add tests for _run_event_loop with limited and unlimited iterations - Add test for process_analysis_output with error exit but no stderr - Add test for process_game_finish with invalid data type - All 85 tests pass with 100% line and branch coverage
758 lines
23 KiB
Python
758 lines
23 KiB
Python
"""Main entry point for the Lichess bot."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
from dataclasses import dataclass, field
|
|
import datetime
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from typing import TYPE_CHECKING
|
|
|
|
import chess
|
|
import chess.pgn
|
|
import requests
|
|
|
|
from python_pkg.lichess_bot.engine import RandomEngine
|
|
from python_pkg.lichess_bot.lichess_api import LichessAPI
|
|
from python_pkg.lichess_bot.utils import backoff_sleep, get_and_increment_version
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Regex for parsing ply numbers from analysis output
|
|
_PLY_LINE_RE = re.compile(r"^\s*(\d+)\s")
|
|
|
|
# Game end statuses
|
|
_GAME_END_STATUSES = frozenset({"mate", "resign", "stalemate", "timeout", "draw"})
|
|
|
|
|
|
@dataclass
|
|
class GameMeta:
|
|
"""Metadata for a game (for PGN headers and logging)."""
|
|
|
|
game_id: str
|
|
bot_version: int
|
|
site_url: str | None = None
|
|
date_iso: str | None = None
|
|
white_name: str | None = None
|
|
black_name: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class GameState:
|
|
"""Mutable state for an ongoing game."""
|
|
|
|
board: chess.Board = field(default_factory=chess.Board)
|
|
color: str | None = None
|
|
last_handled_len: int = -1
|
|
my_ms: int | None = None
|
|
opp_ms: int | None = None
|
|
inc_ms: int = 0
|
|
log_path: Path | None = None
|
|
|
|
|
|
@dataclass
|
|
class BotContext:
|
|
"""Shared context for bot operations."""
|
|
|
|
api: LichessAPI
|
|
engine: RandomEngine
|
|
bot_version: int
|
|
decline_correspondence: bool = False
|
|
|
|
|
|
def _apply_move_to_board(board: chess.Board, move: str, game_id: str) -> None:
|
|
"""Apply a single move to the board, logging errors."""
|
|
try:
|
|
board.push_uci(move)
|
|
except ValueError:
|
|
_logger.debug("Game %s: could not apply move %s", game_id, move)
|
|
|
|
|
|
def _init_game_log(game_id: str, bot_version: int) -> Path | None:
|
|
"""Initialize the game log file."""
|
|
game_log_path = Path.cwd() / f"lichess_bot_game_{game_id}.log"
|
|
try:
|
|
with game_log_path.open("w") as lf:
|
|
lf.write(f"game {game_id} started\n")
|
|
lf.write(f"bot_version v{bot_version}\n")
|
|
except OSError:
|
|
return None
|
|
return game_log_path
|
|
|
|
|
|
def _update_clocks_from_state(state_data: dict[str, object], state: GameState) -> None:
|
|
"""Update clock values from state data."""
|
|
wtime = state_data.get("wtime")
|
|
btime = state_data.get("btime")
|
|
if state.color == "white":
|
|
state.my_ms = int(wtime) if isinstance(wtime, int | float) else None
|
|
state.opp_ms = int(btime) if isinstance(btime, int | float) else None
|
|
else:
|
|
state.my_ms = int(btime) if isinstance(btime, int | float) else None
|
|
state.opp_ms = int(wtime) if isinstance(wtime, int | float) else None
|
|
inc = state_data.get("winc") or state_data.get("binc")
|
|
state.inc_ms = int(inc) if isinstance(inc, int | float) else 0
|
|
|
|
|
|
def _extract_player_info(
|
|
event: dict[str, object], state: GameState, meta: GameMeta, api: LichessAPI
|
|
) -> None:
|
|
"""Extract player info and determine color."""
|
|
white_data = event.get("white", {})
|
|
black_data = event.get("black", {})
|
|
if not isinstance(white_data, dict) or not isinstance(black_data, dict):
|
|
return
|
|
white_id = white_data.get("id")
|
|
black_id = black_data.get("id")
|
|
meta.white_name = str(white_data.get("name") or white_id or "?")
|
|
meta.black_name = str(black_data.get("name") or black_id or "?")
|
|
me = api.get_my_user_id()
|
|
if me == white_id:
|
|
state.color = "white"
|
|
elif me == black_id:
|
|
state.color = "black"
|
|
|
|
|
|
def _extract_game_full_data(
|
|
event: dict[str, object],
|
|
state: GameState,
|
|
meta: GameMeta,
|
|
api: LichessAPI,
|
|
) -> tuple[str, str | None]:
|
|
"""Extract data from a gameFull event.
|
|
|
|
Returns:
|
|
Tuple of (moves_string, status).
|
|
"""
|
|
state_data = event.get("state", {})
|
|
if not isinstance(state_data, dict):
|
|
state_data = {}
|
|
moves = str(state_data.get("moves", ""))
|
|
status = state_data.get("status")
|
|
|
|
_update_clocks_from_state(state_data, state)
|
|
_extract_player_info(event, state, meta, api)
|
|
|
|
# Extract date
|
|
with contextlib.suppress(Exception):
|
|
created_ms = event.get("createdAt") or event.get("createdAtDate")
|
|
if created_ms is not None:
|
|
meta.date_iso = datetime.datetime.fromtimestamp(
|
|
int(str(created_ms)) / 1000,
|
|
tz=datetime.timezone.utc,
|
|
).strftime("%Y.%m.%d")
|
|
|
|
meta.site_url = f"https://lichess.org/{meta.game_id}"
|
|
|
|
return moves, str(status) if status else None
|
|
|
|
|
|
def _extract_game_state_data(
|
|
event: dict[str, object], state: GameState
|
|
) -> tuple[str, str | None]:
|
|
"""Extract data from a gameState event.
|
|
|
|
Returns:
|
|
Tuple of (moves_string, status).
|
|
"""
|
|
moves = str(event.get("moves", ""))
|
|
status = event.get("status")
|
|
|
|
# Update clocks based on color
|
|
if state.color == "white":
|
|
state.my_ms = event.get("wtime", state.my_ms) # type: ignore[assignment]
|
|
state.opp_ms = event.get("btime", state.opp_ms) # type: ignore[assignment]
|
|
state.inc_ms = event.get("winc", state.inc_ms) # type: ignore[assignment]
|
|
elif state.color == "black":
|
|
state.my_ms = event.get("btime", state.my_ms) # type: ignore[assignment]
|
|
state.opp_ms = event.get("wtime", state.opp_ms) # type: ignore[assignment]
|
|
state.inc_ms = event.get("binc", state.inc_ms) # type: ignore[assignment]
|
|
|
|
return moves, str(status) if status else None
|
|
|
|
|
|
def _calculate_time_budget(
|
|
state: GameState, board: chess.Board, max_time_sec: float
|
|
) -> float:
|
|
"""Calculate time budget for the next move."""
|
|
est_moves_left = max(10, min(60, 30 - board.fullmove_number // 2))
|
|
time_left_sec = (state.my_ms or 0) / 1000.0
|
|
inc_sec = (state.inc_ms or 0) / 1000.0
|
|
budget = 0.6 * (time_left_sec / max(1, est_moves_left)) + 0.5 * inc_sec
|
|
# Double the budget for more thoughtful moves
|
|
budget *= 2.0
|
|
return max(0.05, min(max_time_sec, budget))
|
|
|
|
|
|
def _log_move_to_file(
|
|
log_path: Path | None, ply: int, move: chess.Move, reason: str
|
|
) -> None:
|
|
"""Log a move to the game log file."""
|
|
if log_path:
|
|
with log_path.open("a") as lf:
|
|
lf.write(f"ply {ply}: {move.uci()}\n{reason}\n\n")
|
|
|
|
|
|
def _attempt_move(
|
|
ctx: BotContext,
|
|
state: GameState,
|
|
meta: GameMeta,
|
|
board: chess.Board,
|
|
) -> bool:
|
|
"""Attempt to make a move. Returns True if game should continue."""
|
|
budget = _calculate_time_budget(state, board, ctx.engine.max_time_sec)
|
|
move, reason = ctx.engine.choose_move_with_explanation(
|
|
board, time_budget_sec=budget
|
|
)
|
|
|
|
if move is None:
|
|
_logger.info("Game %s: no legal moves (game likely over)", meta.game_id)
|
|
return False
|
|
|
|
time_left_sec = (state.my_ms or 0) / 1000.0
|
|
inc_sec = (state.inc_ms or 0) / 1000.0
|
|
|
|
try:
|
|
if move not in board.legal_moves:
|
|
_logger.info(
|
|
"Game %s: selected move no longer legal; skipping send", meta.game_id
|
|
)
|
|
else:
|
|
_logger.info(
|
|
"Game %s: playing %s (budget=%.2fs, my_time_left=%.1fs, inc=%.2fs)",
|
|
meta.game_id,
|
|
move.uci(),
|
|
budget,
|
|
time_left_sec,
|
|
inc_sec,
|
|
)
|
|
_log_move_to_file(state.log_path, state.last_handled_len + 1, move, reason)
|
|
ctx.api.make_move(meta.game_id, move)
|
|
except requests.RequestException as e:
|
|
_logger.warning("Game %s: move %s failed: %s", meta.game_id, move.uci(), e)
|
|
|
|
return True
|
|
|
|
|
|
def _is_my_turn(board: chess.Board, color: str | None) -> bool:
|
|
"""Check if it's our turn to move."""
|
|
is_white_turn = board.turn
|
|
return (is_white_turn and color == "white") or (
|
|
(not is_white_turn) and color == "black"
|
|
)
|
|
|
|
|
|
def _rebuild_board_from_moves(moves_list: list[str], game_id: str) -> chess.Board:
|
|
"""Rebuild board from list of moves."""
|
|
board = chess.Board()
|
|
for m in moves_list:
|
|
_apply_move_to_board(board, m, game_id)
|
|
return board
|
|
|
|
|
|
def _handle_move_if_needed(
|
|
ctx: BotContext,
|
|
state: GameState,
|
|
meta: GameMeta,
|
|
et: str,
|
|
new_len: int,
|
|
) -> bool:
|
|
"""Handle making a move if it's our turn. Returns False if game ends."""
|
|
my_turn = _is_my_turn(state.board, state.color)
|
|
turn_str = "white" if state.board.turn else "black"
|
|
_logger.info("Game %s: turn=%s, my_turn=%s", meta.game_id, turn_str, my_turn)
|
|
|
|
# Move policy
|
|
allow_move = (et == "gameState") or (et == "gameFull" and not new_len)
|
|
|
|
if my_turn and allow_move and not _attempt_move(ctx, state, meta, state.board):
|
|
return False
|
|
|
|
# Mark position as handled
|
|
if et == "gameState" or (my_turn and allow_move):
|
|
state.last_handled_len = new_len
|
|
|
|
return True
|
|
|
|
|
|
def _process_game_event(
|
|
event: dict[str, object],
|
|
ctx: BotContext,
|
|
state: GameState,
|
|
meta: GameMeta,
|
|
) -> bool:
|
|
"""Process a single game event. Returns False if game should end."""
|
|
et = event.get("type")
|
|
|
|
if et not in ("gameFull", "gameState"):
|
|
return True # Continue processing other events
|
|
|
|
# At this point et is guaranteed to be a string
|
|
event_type = str(et)
|
|
|
|
# Extract moves and status based on event type
|
|
if event_type == "gameFull":
|
|
moves, status = _extract_game_full_data(event, state, meta, ctx.api)
|
|
_logger.info("Game %s: joined as %s (gameFull)", meta.game_id, state.color)
|
|
else:
|
|
moves, status = _extract_game_state_data(event, state)
|
|
|
|
moves_list = moves.split() if moves else []
|
|
new_len = len(moves_list)
|
|
|
|
_logger.info(
|
|
"Game %s: event=%s, moves=%s, color=%s",
|
|
meta.game_id,
|
|
event_type,
|
|
new_len,
|
|
state.color,
|
|
)
|
|
|
|
if new_len == state.last_handled_len:
|
|
_logger.debug(
|
|
"Game %s: position unchanged (len=%s), skipping", meta.game_id, new_len
|
|
)
|
|
return True
|
|
|
|
# Rebuild board from moves
|
|
state.board = _rebuild_board_from_moves(moves_list, meta.game_id)
|
|
|
|
if state.color is None:
|
|
_logger.info("Game %s: color unknown yet; waiting for gameFull", meta.game_id)
|
|
if event_type == "gameState":
|
|
state.last_handled_len = new_len
|
|
return True
|
|
|
|
if not _handle_move_if_needed(ctx, state, meta, event_type, new_len):
|
|
return False
|
|
|
|
# Check for game end
|
|
if status in _GAME_END_STATUSES:
|
|
_logger.info("Game %s finished: %s", meta.game_id, status)
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _write_pgn_to_log(log_path: Path, board: chess.Board, meta: GameMeta) -> None:
|
|
"""Write PGN to the game log file."""
|
|
game = chess.pgn.Game.from_board(board)
|
|
with contextlib.suppress(Exception):
|
|
game.headers["BotVersion"] = f"v{meta.bot_version}"
|
|
if meta.site_url:
|
|
game.headers["Site"] = meta.site_url
|
|
if meta.date_iso:
|
|
game.headers["Date"] = meta.date_iso
|
|
if meta.white_name:
|
|
game.headers["White"] = meta.white_name
|
|
if meta.black_name:
|
|
game.headers["Black"] = meta.black_name
|
|
|
|
with log_path.open("a") as lf:
|
|
lf.write("\nPGN:\n")
|
|
exporter = chess.pgn.StringExporter(
|
|
headers=True, variations=False, comments=False
|
|
)
|
|
lf.write(game.accept(exporter))
|
|
lf.write("\n")
|
|
|
|
|
|
def _run_analysis_subprocess(
|
|
game_id: str, log_path: Path, total_plies: int
|
|
) -> str | None:
|
|
"""Run the analysis script and return output text."""
|
|
analyze_script = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "stockfish_analysis"
|
|
/ "analyze_chess_game.py"
|
|
)
|
|
|
|
if not analyze_script.is_file():
|
|
_logger.info(
|
|
"Game %s: analysis script not found at %s; skipping analysis",
|
|
game_id,
|
|
analyze_script,
|
|
)
|
|
return None
|
|
|
|
_logger.info(
|
|
"Game %s: starting post-game analysis (%s plies)", game_id, total_plies
|
|
)
|
|
|
|
# S603: subprocess call is safe - analyze_script is validated with is_file()
|
|
# above and all arguments are explicit strings from trusted sources
|
|
with subprocess.Popen(
|
|
[sys.executable, "-u", str(analyze_script), str(log_path)],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
) as proc:
|
|
return _process_analysis_output(proc, game_id, total_plies)
|
|
|
|
|
|
def _process_analysis_output(
|
|
proc: subprocess.Popen[str], game_id: str, total_plies: int
|
|
) -> str | None:
|
|
"""Process analysis subprocess output and return analysis text."""
|
|
# stdout/stderr are guaranteed non-None with PIPE, but verify at runtime
|
|
if proc.stdout is None or proc.stderr is None:
|
|
proc.terminate()
|
|
msg = "subprocess pipes unexpectedly None"
|
|
raise RuntimeError(msg)
|
|
|
|
__analyzed, lines = _collect_analysis_lines(proc.stdout, game_id, total_plies)
|
|
|
|
stderr_text = proc.stderr.read() or ""
|
|
ret = proc.wait()
|
|
analysis_text = "".join(lines)
|
|
|
|
if ret:
|
|
_logger.warning("Game %s: analysis script exited with code %s", game_id, ret)
|
|
if stderr_text:
|
|
analysis_text += "\n[stderr]\n" + stderr_text
|
|
|
|
_logger.info("Game %s: analysis complete", game_id)
|
|
return analysis_text
|
|
|
|
|
|
def _collect_analysis_lines(
|
|
stdout: Iterator[str], game_id: str, total_plies: int
|
|
) -> tuple[int, list[str]]:
|
|
"""Collect and process analysis lines from stdout.
|
|
|
|
Returns:
|
|
Tuple of (analyzed_count, lines_list).
|
|
"""
|
|
analyzed = 0
|
|
lines: list[str] = []
|
|
while True:
|
|
try:
|
|
line = next(stdout)
|
|
except StopIteration:
|
|
break
|
|
lines.append(line)
|
|
m = _PLY_LINE_RE.match(line)
|
|
if m:
|
|
analyzed += 1
|
|
_log_analysis_progress(game_id, analyzed, total_plies)
|
|
return analyzed, lines
|
|
|
|
|
|
def _log_analysis_progress(game_id: str, analyzed: int, total_plies: int) -> None:
|
|
"""Log analysis progress."""
|
|
if total_plies:
|
|
left = max(0, total_plies - analyzed)
|
|
pct = analyzed / total_plies * 100.0
|
|
_logger.info(
|
|
"Game %s: analysis progress %s/%s (%.0f%%), left %s",
|
|
game_id,
|
|
analyzed,
|
|
total_plies,
|
|
pct,
|
|
left,
|
|
)
|
|
else:
|
|
_logger.info(
|
|
"Game %s: analysis progress %s plies (total unknown)", game_id, analyzed
|
|
)
|
|
|
|
|
|
def _insert_analysis_into_log(
|
|
log_path: Path, analysis_text: str, meta: GameMeta
|
|
) -> None:
|
|
"""Insert analysis text into the log file before PGN section."""
|
|
try:
|
|
with log_path.open(encoding="utf-8", errors="replace") as f:
|
|
content = f.read()
|
|
|
|
# Find insertion point (before PGN)
|
|
insert_idx = 0
|
|
p = content.find("\nPGN:\n")
|
|
if p != -1:
|
|
insert_idx = p + 1
|
|
elif content.startswith("PGN:\n"):
|
|
insert_idx = 0
|
|
else:
|
|
insert_idx = len(content)
|
|
|
|
# Build meta block
|
|
meta_lines = []
|
|
if meta.date_iso:
|
|
meta_lines.append(f"Date: {meta.date_iso}")
|
|
if meta.white_name or meta.black_name:
|
|
meta_lines.append(
|
|
f"Players: {meta.white_name or '?'} vs {meta.black_name or '?'}"
|
|
)
|
|
meta_block = "\n".join(meta_lines) + "\n" if meta_lines else ""
|
|
|
|
analysis_block = f"{meta_block}ANALYSIS:\n{analysis_text.rstrip()}\n\n"
|
|
new_content = content[:insert_idx] + analysis_block + content[insert_idx:]
|
|
|
|
with log_path.open("w", encoding="utf-8") as f:
|
|
f.write(new_content)
|
|
except OSError as e:
|
|
_logger.debug("Game %s: could not write analysis to log: %s", meta.game_id, e)
|
|
|
|
|
|
def _finalize_game(state: GameState, meta: GameMeta) -> None:
|
|
"""Finalize game: write PGN and run analysis."""
|
|
if not state.log_path:
|
|
return
|
|
|
|
try:
|
|
_write_pgn_to_log(state.log_path, state.board, meta)
|
|
except OSError as e:
|
|
_logger.debug("Game %s: could not write PGN: %s", meta.game_id, e)
|
|
return
|
|
|
|
# Run analysis
|
|
try:
|
|
total_plies = len(state.board.move_stack)
|
|
except TypeError:
|
|
total_plies = 0
|
|
|
|
try:
|
|
analysis_text = _run_analysis_subprocess(
|
|
meta.game_id, state.log_path, total_plies
|
|
)
|
|
if analysis_text:
|
|
_insert_analysis_into_log(state.log_path, analysis_text, meta)
|
|
except (subprocess.SubprocessError, OSError) as e:
|
|
_logger.debug("Game %s: analysis run failed: %s", meta.game_id, e)
|
|
|
|
|
|
def _process_game_events_loop(
|
|
events: Iterator[dict[str, object]],
|
|
ctx: BotContext,
|
|
state: GameState,
|
|
meta: GameMeta,
|
|
) -> None:
|
|
"""Process game events from an iterator until game ends.
|
|
|
|
This is extracted to allow testing the loop exhaustion branch.
|
|
"""
|
|
while True:
|
|
try:
|
|
event = next(events)
|
|
except StopIteration:
|
|
break
|
|
et = event.get("type")
|
|
if et in ("chatLine", "opponentGone"):
|
|
continue
|
|
if not _process_game_event(event, ctx, state, meta):
|
|
break
|
|
|
|
|
|
def _handle_game(game_id: str, ctx: BotContext, my_color: str | None = None) -> None:
|
|
"""Handle a single game from start to finish."""
|
|
_logger.info("Starting game thread for %s [bot v%s]", game_id, ctx.bot_version)
|
|
|
|
meta = GameMeta(game_id=game_id, bot_version=ctx.bot_version)
|
|
state = GameState(color=my_color)
|
|
state.log_path = _init_game_log(game_id, ctx.bot_version)
|
|
|
|
try:
|
|
events = ctx.api.stream_game_events(game_id)
|
|
_process_game_events_loop(events, ctx, state, meta)
|
|
except requests.RequestException:
|
|
_logger.exception("Game %s thread error", game_id)
|
|
finally:
|
|
_finalize_game(state, meta)
|
|
_logger.info("Ending game thread for %s", game_id)
|
|
|
|
|
|
def _handle_challenge(
|
|
challenge: dict[str, object], api: LichessAPI, *, decline_correspondence: bool
|
|
) -> None:
|
|
"""Handle an incoming challenge."""
|
|
ch_id = challenge.get("id", "")
|
|
variant_data = challenge.get("variant", {})
|
|
variant = (
|
|
variant_data.get("key", "standard")
|
|
if isinstance(variant_data, dict)
|
|
else "standard"
|
|
)
|
|
speed = challenge.get("speed")
|
|
|
|
perf_ok = speed in {"bullet", "blitz", "rapid", "classical"}
|
|
not_corr = speed != "correspondence" or not decline_correspondence
|
|
|
|
if variant == "standard" and perf_ok and not_corr:
|
|
_logger.info("Accepting challenge %s (%s)", ch_id, speed)
|
|
api.accept_challenge(str(ch_id))
|
|
else:
|
|
_logger.info(
|
|
"Declining challenge %s (variant=%s, speed=%s)", ch_id, variant, speed
|
|
)
|
|
api.decline_challenge(str(ch_id))
|
|
|
|
|
|
def _process_bot_event(
|
|
event: dict[str, object],
|
|
ctx: BotContext,
|
|
game_threads: dict[str, threading.Thread],
|
|
) -> None:
|
|
"""Process a single bot event (challenge, gameStart, etc.)."""
|
|
event_type = event.get("type")
|
|
|
|
if event_type == "challenge":
|
|
challenge = event.get("challenge", {})
|
|
if isinstance(challenge, dict):
|
|
_handle_challenge(
|
|
challenge, ctx.api, decline_correspondence=ctx.decline_correspondence
|
|
)
|
|
|
|
elif event_type == "gameStart":
|
|
game_data = event.get("game", {})
|
|
if isinstance(game_data, dict):
|
|
game_id = str(game_data.get("id", ""))
|
|
if game_id and (
|
|
game_id not in game_threads or not game_threads[game_id].is_alive()
|
|
):
|
|
t = threading.Thread(
|
|
target=_handle_game,
|
|
args=(game_id, ctx),
|
|
name=f"game-{game_id}",
|
|
)
|
|
t.daemon = True
|
|
game_threads[game_id] = t
|
|
t.start()
|
|
|
|
elif event_type == "gameFinish":
|
|
game_data = event.get("game", {})
|
|
if isinstance(game_data, dict):
|
|
game_id = game_data.get("id", "")
|
|
_logger.info("Game finished event: %s", game_id)
|
|
|
|
else:
|
|
_logger.debug("Unhandled event: %s", json.dumps(event))
|
|
|
|
|
|
def _stream_bot_events(ctx: BotContext) -> Iterator[dict[str, object]]:
|
|
"""Stream events from Lichess API with type hints."""
|
|
yield from ctx.api.stream_events()
|
|
|
|
|
|
def _run_event_loop_iteration(
|
|
ctx: BotContext, game_threads: dict[str, threading.Thread]
|
|
) -> int:
|
|
"""Run one iteration of the event loop.
|
|
|
|
Returns:
|
|
New backoff value (0 on success).
|
|
"""
|
|
for event in _stream_bot_events(ctx):
|
|
_process_bot_event(event, ctx, game_threads)
|
|
return 0
|
|
|
|
|
|
def _safe_event_loop_iteration(
|
|
ctx: BotContext, game_threads: dict[str, threading.Thread], backoff: int
|
|
) -> int:
|
|
"""Run event loop iteration with error handling.
|
|
|
|
This wrapper exists to avoid try-except inside while True loop (PERF203).
|
|
|
|
Returns:
|
|
New backoff value.
|
|
"""
|
|
try:
|
|
return _run_event_loop_iteration(ctx, game_threads)
|
|
except requests.RequestException as e:
|
|
_logger.warning("Event stream error: %s", e)
|
|
return backoff_sleep(backoff)
|
|
|
|
|
|
def run_bot(
|
|
log_level: str = "INFO",
|
|
*,
|
|
decline_correspondence: bool = False,
|
|
max_iterations: int | None = None,
|
|
) -> None:
|
|
"""Start the bot and listen for incoming events.
|
|
|
|
Args:
|
|
log_level: Logging level (default: INFO).
|
|
decline_correspondence: Whether to decline correspondence challenges.
|
|
max_iterations: Maximum event loop iterations (None for infinite).
|
|
Used for testing.
|
|
"""
|
|
logging.basicConfig(
|
|
level=getattr(logging, log_level.upper(), logging.INFO),
|
|
format="[%(asctime)s] %(levelname)s %(threadName)s: %(message)s",
|
|
)
|
|
|
|
token = os.getenv("LICHESS_TOKEN")
|
|
if not token:
|
|
msg = "LICHESS_TOKEN environment variable is required"
|
|
raise RuntimeError(msg)
|
|
|
|
_logger.info("Token present. Initializing client and engine...")
|
|
bot_version = get_and_increment_version()
|
|
_logger.info("Bot version: v%s", bot_version)
|
|
|
|
ctx = BotContext(
|
|
api=LichessAPI(token),
|
|
engine=RandomEngine(),
|
|
bot_version=bot_version,
|
|
decline_correspondence=decline_correspondence,
|
|
)
|
|
|
|
game_threads: dict[str, threading.Thread] = {}
|
|
|
|
_logger.info("Connecting to Lichess event stream. Waiting for challenges...")
|
|
backoff = 0
|
|
|
|
_run_event_loop(ctx, game_threads, backoff, max_iterations)
|
|
|
|
|
|
def _run_event_loop(
|
|
ctx: BotContext,
|
|
game_threads: dict[str, threading.Thread],
|
|
backoff: int,
|
|
max_iterations: int | None,
|
|
) -> None:
|
|
"""Run the main event loop.
|
|
|
|
Args:
|
|
ctx: Bot context.
|
|
game_threads: Dictionary of active game threads.
|
|
backoff: Initial backoff value.
|
|
max_iterations: Maximum iterations (None for infinite).
|
|
"""
|
|
iteration = 0
|
|
while max_iterations is None or iteration < max_iterations:
|
|
backoff = _safe_event_loop_iteration(ctx, game_threads, backoff)
|
|
iteration += 1
|
|
|
|
|
|
def main() -> None:
|
|
"""Parse arguments and run the Lichess bot."""
|
|
parser = argparse.ArgumentParser(description="Run a minimal Lichess bot")
|
|
parser.add_argument(
|
|
"--log-level", default="INFO", help="Logging level (default: INFO)"
|
|
)
|
|
parser.add_argument(
|
|
"--decline-correspondence",
|
|
action="store_true",
|
|
help="Decline correspondence challenges",
|
|
)
|
|
args = parser.parse_args()
|
|
run_bot(args.log_level, decline_correspondence=args.decline_correspondence)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|