diff --git a/.env b/.env new file mode 100644 index 0000000..804629f --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +API_BASE_URL= +APP_SECRET_FILE=chesspuzzlebot-app.secret +USER_SECRET_FILE=chesspuzzlebot-user.secret +# If empty, interactive prompt +MASTODON_USER_EMAIL= +MASTODON_PASSWORD= +SQLITE_DB=db.sqlite diff --git a/README.md b/README.md index 6127e6c..28cc862 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,138 @@ -# chess-puzzle-bot +# chess-puzzle-bot  +  +[Mastodon](https://docs.joinmastodon.org/) bot for chess puzzles.  +  +## Introduction  +  +This is an interactive bot written in Python to play  +chess puzzles via messages in the Mastodon social network.  +  +It's based in the API implementation from the [Mastodon.py](https://mastodonpy.readthedocs.io/en/stable/) library  +and the chess game implementation from [python-chess](https://python-chess.readthedocs.io/en/stable/).  + +Screen sample: + +![Screen capture sample](img/screenshot.jpg)  +  +The bot is designed to be accesible to users with visual disability. All the board images are posted with ALT text using the standard FEN notation, that can be read and interpreted by most devices automatically. + +You can find the original instance of this bot running in [@ChessPuzzleBot@masto.es](https://masto.es/@ChessPuzzleBot)  +  +## Puzzles database  +  +The bot works with the awesome puzzles database from [Lichess](https://lichess.org).  +  +Lichess is the world's greatest open source non-profit chess server. They release their databases freely under the Creative Commons CC0 license.  +  +The puzzles database is available for download in [this page](https://database.lichess.org/#puzzles) as a huge CSV file.  +  +## Installation  +  +You need Python 3.11 or greater with PIP and setuptools.  +  +The Python dependencies are listed in the `requirements.txt` file, all installable via PIP.  +  +The only system dependency is the [Cairo graphics library](https://www.cairographics.org/) (needed to convert the board images).  +  +In Debian and related systems (Ubuntu, etc.), install Cairo library with:  +  +`sudo apt install libcairo2 libcairo2-dev`  +  +In other Linux systems, use its convenient module system to get the Cairo library in a similar way.  +  +In Windows systems, I found that the Cairo library is bundled in the Python package, so usually you don't need to install it separately.  +  +Then, install the Python requirements (use system-wide PIP or, preferably, in a [virtualenv](https://virtualenv.pypa.io/en/latest/):  +  +`pip install -r requirements.txt`  +  +## Database setup  +  +An utility script is provided to generate the [SQLite](https://www.sqlite.org/) database  +used to index the puzzles and store user results.  +  +Get the `lichess_db_puzzle.csv.zst` file from the Lichess downloads page. Uncompress it with unzstd or other compatible tool.  +Then, generate the database with the command:  +  +`python3 dbsetup.py lichess_db_puzzle.csv db.sqlite 95`  +  +This command generates a `db.sqlite` file with all puzzles with popularity rate greater than 95.  +Currently, it's a huge table of 800000+ puzzles and 125 MB in size.  +If you want more puzzles, simply low down the popularity filter, but you could get a very huge file.  +The complete Lichess database holds currently more than 4 million puzzles!  +  +You have to be patient with the setup script. It will take a long time to store and index the SQLite database.  +After it, you don't need the CSV file anymore.  +  +It will also generate a tiny `tags.json` file with the distinct tags  +found in the puzzles. The tags are assigned by Lichess from known themes and strategies. The complete tags description can be found [here](https://github.com/lichess-org/lila/blob/master/translation/source/puzzleTheme.xml).  +  +## Environment  +  +Use the variables in the `.env` file to provide runtime parameters to the bot.  +You can also create a `.env.local` file to isolate some variables if you want to use some versioning system.  +  +The variables are:  +  +- API_BASE_URL: URL to your Mastodon server. **NOTE! Every server instance has its own rules regarding bots. PLEASE read the rules before running the bot over there. +- APP_SECRET_FILE and USER_SECRET_FILE: Path to the files where the API will store the app and user authentication. The files will be generated in the first run, and then you will not need to enter the credentials anymore. +- MASTODON_USER_EMAIL: Email registered in the server for the bot account. You must have a bot account in a Mastodon instance before running the bot. **Please refer to the [documentation](https://docs.joinmastodon.org/user/signup/) about signing up accounts and bot settings. +- MASTODON_PASSWORD: The password of the bot account. If you leave this variable empty, the password will be prompted interactively in the first run. +- SQLITE_DB: Path to the SQLite file generated by the setup utility. + +## Starting up + +Once you have installed the dependencies, and generated the database, the tags.json file and the environment variables, you can run the bot simply: + +`python3 chesspuzzlebot.py` + +The program will set up the authentication (first run), and start listening the events notified by the server about user interactions.  + +The program is designed to run unattended without end. In case of connection is lost or any other errors, it will try to reconnect automatically. + +There's an hourly routine that will post the "daily puzzle" and other challenges randomly to encourage followers to participate. + +The bot will keep the command line open, sending all its logs to the standard output. You can follow the suitable procedure (to your operating system) to keep the bot running in background as a service or daemon. You can also change the logging settings in the chesspuzzlebot.py script to redirect logs to a file or any other destination, and to change the log level. Refer to the [python logging documentation](https://docs.python.org/3/library/logging.html) for all available settings. + +## Multilanguage support + +The bot is created originally to talk either on Spanish or English. More languages can be added setting up more translation files on the "lang" directory. No more setup needed. VOLUNTEER TRANSLATORS WELCOME! + +The bot will respond to commands in the same language in which they are written. Therefore, when creating new languages, it is important to always use different words for commands. + +## Using the bot + +Any Mastodon user (including from other federated instances) can reply to puzzles. The replies must be sent private (direct message) to respect other users and not to spoil the solutions. The bot will reject any non-private message. + +You can reply to a puzzle with the next move that you consider the best. You can use PGN notation (such as Kh8) or UCI (such as h7h8). Use the correct capitalization, according to the rules of the notation.  + +It will reply to you if you got it right or wrong, and you can continue with the following moves.  + +If you do not know how to continue a puzzle, at any time you can ask me for the solution, by sending the answer "resolve". I will give you the next move, which will count as a fail, and you can continue the puzzle if you wish.  + +Users can: +- Play the daily puzzle that is published every morning. +- Ask privately for all the puzzles they want (sending the command "new").  +- Ask for puzzles by theme. To do this, instead of "new", send one of the tags supported by Lichess (the ones in the tags.json file).  + +Apart from the moves or tags, the user can send these commands to the bot: +- **new**: Get a new private puzzle. +- **resolve**: Solve the next move.  +- **results**: Get stats from your played puzzles.  +- **hide**: Tell the bot that you want to keep all your data private. The bot will not share any of your results with other users. It's the default option.  +- **show**: Tell the bot that you want to share your results with other users.  +- **help**: Get instructions about how to use the bot.  +- **hard**, medium, easy or all: Tell it that level of difficulty do you want for your private puzzles. Default is "all". +- **tags**: Request a list of supported Lichess tags.  +- **delete**: Request to delete all your data stored in the database. The bot will forget all your activity, preferences and results.  + +## Acknowledgements + +This bot is made and maintained by @ElPamplina@masto.es as a side project for amusement only. + +- Thanks to [Lichess](https://lichess.org) for all the puzzles. Thanks to Thibault Duplessis for creating and maintaining this awesome chess site. +- Thanks to Lorenz Diener for creating the Mastodon.py API wrapper. +- Thanks to Eugen Rochko and all the Mastodon team for creating the best open and free social network ruled by the people. +- Thanks to Niklas Fiekas for maintaining the python-chess library. +- And finally, thanks to the open source community for making the world better for all.  -Mastodon bot for chess puzzles. -UNDER DEVELOPMENT \ No newline at end of file diff --git a/chesspuzzlebot.py b/chesspuzzlebot.py new file mode 100644 index 0000000..69147bb --- /dev/null +++ b/chesspuzzlebot.py @@ -0,0 +1,371 @@ +import getpass +import json +import logging.config +import os +import random +import re +import sys +import time +from datetime import datetime + +import yaml +from dotenv import load_dotenv +from mastodon import Mastodon, StreamListener, MastodonServerError, MastodonRatelimitError, MastodonAPIError, \ + MastodonError + +from chessutils import get_board_image, is_uci_move, is_pgn_move, pgn2uci, play_uci_moves +from dbutils import find_played, log_move, get_puzzle_results, \ + get_private_puzzle, update_private_puzzle, set_user_level, set_user_hidden, delete_user, get_user_stats, \ + get_daily_puzzle, update_daily_puzzle, insert_fake_daily_puzzle, get_puzzle_completed_points, find_challenge + +# CHESS PUZZLE BOT for MASTODON +# Made by @ElPamplina@masto.es +# See LICENSE for details. + +dotenv_path = os.path.join(os.path.dirname(__file__), '.env.local') +if os.path.exists(dotenv_path): + load_dotenv(dotenv_path) +load_dotenv() + +logging.config.dictConfig( + { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + 'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s' + }, + }, + 'handlers': { + 'console': { + 'level': 'DEBUG', + 'formatter': 'standard', + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + '': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': True + }, + } + } +) +logger = logging.getLogger('chesspuzzlebot') + +# Load text files +with open('texts.yml', encoding='utf-8') as f: + texts = yaml.safe_load(f) +lang = {} +for file in os.listdir('lang'): + if file.endswith('.yml'): + with open(os.path.join('lang', file), encoding='utf-8') as f: + lang[os.path.splitext(file)[0]] = yaml.safe_load(f) + +# Index commands +commands = {} +for code, keys in lang.items(): + for c, v in keys['commands'].items(): + commands[c] = v, code + +# Load tags file +with open('tags.json') as f: + all_tags = json.load(f) + +# Trying to load already configured Mastodon connection +app_file = os.getenv('APP_SECRET_FILE') +user_file = os.getenv('USER_SECRET_FILE') +if os.path.isfile(app_file) and os.path.isfile(user_file): + logger.info(f"Using existing credentials from {app_file} {user_file}") + mastodon = Mastodon(client_secret=app_file, access_token=user_file) +else: + # Configure new connection + url = os.getenv('API_BASE_URL', '') + if not url: + logger.error('API_BASE_URL not defined.') + sys.exit(1) + # Create app and user credentials + user_email = os.getenv('MASTODON_USER_EMAIL', '') + pwd = os.getenv('MASTODON_PASSWORD', '') + if not user_email: + user_email = input('Username: ') + if not pwd: + pwd = getpass.getpass('Password: ') + logger.info(f"Usuario: {user_email} Pass: /{pwd}/") + Mastodon.create_app('ChessPuzzleBot', api_base_url=url, to_file=app_file) + mastodon = Mastodon(client_id=app_file) + mastodon.log_in(user_email, pwd, to_file=user_file) + + +def translate(language, string): + if language in lang: + return lang[language]['messages'][string] + else: + return lang['en']['messages'][string] + + +def get_puzzle_played_so_far(reply_id, user): + current_id = reply_id + played = find_played(current_id, user) + while played is None: + # Try reply by reply + replied = mastodon.status(current_id) + if replied.in_reply_to_id is None: + break + else: + current_id = replied.in_reply_to_id + played = find_played(current_id, user) + return played + + +def post_fake_daily_puzzle(id, fen, moves, user, points): + if insert_fake_daily_puzzle(id): + png, image_fen, side = get_board_image(fen, moves.split()[0:1]) + text = texts['shared'].format( + side_en=lang['en'][side], side_es=lang['es'][side], points=points, user=user) + logger.info(f'Sending fake daily puzzle: {id}') + new_media = mastodon.media_post(png, mime_type='image/png', description=image_fen) + new_status = mastodon.status_post(text, media_ids=new_media, visibility='unlisted') + logger.info(f"Posted {new_status.url}") + update_daily_puzzle(id, new_status.id) + + +# Define listener +class ChessbotListener(StreamListener): + def on_notification(self, status): + logger.debug(str(status)) + if status.type == 'mention': + user = status.account.acct + text = re.sub(r'<[^<]+?>', '', status.status.content) + logger.info(f"Mention from {user}: {text}") + uci_move = None + pgn_move = None + command = None + tag = None + language = status.status.language + response = None + image = None + image_fen = None + err = None + new_puzzle_id = None + if text.count('@') > 1: + logger.info(f"Foreign mention ignored.") + elif status.status.visibility != 'direct': + err = translate(language, 'public_rejected') + logger.info(f"Public mention rejected.") + else: + for chunk in text.split(): + c = chunk.lower() + if is_uci_move(c): + uci_move = c + pgn_move = None + elif is_pgn_move(chunk): + # PGN only supports case-sensitive + pgn_move = chunk + uci_move = None + elif c in commands: + command, language = commands[c] + elif c in [t.lower() for t in all_tags]: + tag = c + # Don't collide with "new" + if command == 'new': + command = None + if not ((uci_move is not None) ^ (pgn_move is not None) ^ (command is not None) ^ (tag is not None)): + # Only one mode allowed at a time + err = translate(language, 'too_many_commands') + logger.debug(f"uci_move = {uci_move} pgn_move = {pgn_move} " + f"command = {command} tag = {tag} language = {language} err = {err}") + if uci_move is None and pgn_move is None and command is None and tag is None: + err = translate(language, 'bad_command') + if err is None: + if command == 'help': + response = translate(language, 'help_text') + elif command == 'tags': + response = '\n'.join(all_tags) + elif command in ('hard', 'medium', 'easy', 'all'): + set_user_level(user, command) + response = translate(language, f'level_{command}') + elif command == 'hide': + set_user_hidden(user, 1) + response = translate(language, f'set_hidden') + elif command == 'show': + set_user_hidden(user, 0) + response = translate(language, f'set_public') + # Share this puzzle if it's complete and user won with 0 fails + played = get_puzzle_played_so_far(status.status.in_reply_to_id, user) + if played is not None: + id, fen, moves, rating, tags, order_num, moves_so_far, completed = played + if completed == 1: + points = get_puzzle_completed_points(id, user) + if points is not None: + # Puzzle is logged as if was daily + post_fake_daily_puzzle(id, fen, moves, user, points) + elif command == 'delete': + response = translate(language, 'deleting') + elif command == 'confirm': + delete_user(user) + response = translate(language, 'deleted') + elif command == 'results': + total, solved, average, ranking = get_user_stats(user) + response = translate(language, 'results').format(total=total, solved=solved, average=average) + if solved > 0: + response += f"\n{translate(language, 'ranking').format(ranking=ranking)}" + elif command == 'resolve': + logger.debug(f"Resolve next move") + played = get_puzzle_played_so_far(status.status.in_reply_to_id, user) + if played is None: + response = translate(language, 'bad_command') + else: + id, fen, moves, rating, tags, order_num, moves_so_far, completed = played + if completed == 1 or get_puzzle_completed_points(id, user) is not None: + response = translate(language, 'already_completed') + else: + moves = moves.split() + moves_so_far = moves_so_far.split() + continued = moves[0:len(moves_so_far) + 2] + if len(continued) == len(moves): + continued.append('END') + image, image_fen, side = get_board_image(fen, continued) + if continued[-1] == 'END': + log_move(id, None, continued, order_num + 1, user, status.status.id, 0, 1) + response = translate(language, 'finished').format(move=continued[-2], tags=tags) + else: + log_move(id, None, continued, order_num + 1, user, status.status.id, 0, 0) + response = (f"{translate(language, 'solved_move').format(move=continued[-2])}\n" + f"{translate(language, 'continue_' + side)}") + elif command == 'new' or tag is not None: + logger.debug(f'Get new private puzzle. Tag: {tag}') + np = get_private_puzzle(user=user, tag=tag) + if np is not None: + new_puzzle_id, fen, moves = np + image, image_fen, side = get_board_image(fen, moves.split()[0:1]) + response = translate(language, f"new_{side}") + logger.info(f'New private puzzle for {user}: {new_puzzle_id}') + elif uci_move is not None or pgn_move is not None: + logger.debug(f"Playing: {uci_move or pgn_move}") + # Find puzzle played and expected next move + played = get_puzzle_played_so_far(status.status.in_reply_to_id, user) + if played is None: + response = translate(language, 'bad_command') + else: + id, fen, moves, rating, tags, order_num, moves_so_far, completed = played + if completed == 1 or get_puzzle_completed_points(id, user) is not None: + response = translate(language, 'already_completed') + else: + moves = moves.split() + moves_so_far = moves_so_far.split() + if pgn_move is not None: + # Convert to UCI to compare + new_fen = play_uci_moves(fen, moves_so_far) + played_move = pgn2uci(new_fen, pgn_move) + else: + played_move = uci_move + continued = moves[0:len(moves_so_far) + 2] + if len(continued) == len(moves): + # Dummy response to align + continued.append('END') + if continued[-2] == played_move: + image, image_fen, side = get_board_image(fen, continued) + if continued[-1] == 'END': + log_move(id, uci_move or pgn_move, continued, order_num + 1, user, status.status.id, 1, 1) + points, wrong, total, average, hidden = get_puzzle_results(id, user) + if wrong == 0: + response = lang[language]['messages']['win'].format(points=points, total=total, average=average, tags=tags) + if hidden == 1: + response += "\n" + lang[language]['messages']['hidden'] + elif hidden == 0: + response += "\n" + lang[language]['messages']['public'] + else: + response = lang[language]['messages']['no_win'].format(wrong=wrong, total=total, average=average, tags=tags) + else: + log_move(id, uci_move or pgn_move, continued, order_num + 1, user, status.status.id, 1, 0) + response = f"{lang[language]['messages']['correct']}\n{lang[language]['messages']['continue_' + side]}" + else: + log_move(id, uci_move or pgn_move, moves_so_far, order_num + 1, user, status.status.id, 0, 0) + response = lang[language]['messages']['incorrect'] + else: + response = err + # Send reply + if response is not None: + logger.debug(f"Response: {response}") + retries = 0 + while retries < 10: + retries += 1 + try: + if image is not None: + media = mastodon.media_post(image, mime_type='image/png', description=image_fen) + else: + media = None + if isinstance(response, list): + reply_id = status.status.id + for r in response: + post = mastodon.status_post( + f"@{user} {r}", + in_reply_to_id=reply_id, + language=language, + visibility='direct', + media_ids=media + ) + logger.info(f"Posted: {post.url}") + reply_id = post.id + media = None + else: + post = mastodon.status_post( + f"@{user} {response}", + in_reply_to_id=status.status.id, + language=language, + visibility='direct', + media_ids=media + ) + logger.info(f"Posted: {post.url}") + if new_puzzle_id is not None: + update_private_puzzle(new_puzzle_id, user, post.id) + break + except (MastodonServerError, MastodonRatelimitError, MastodonAPIError) as ex: + logger.error(f"Error posting (try {retries}): {str(ex)}") + time.sleep(5 * retries) + else: + logger.warning("Can't tell any response") + + +logger.info('Listening...') +while True: + try: + handler = mastodon.stream_user(ChessbotListener(), run_async=True, reconnect_async=True) + while True: + time.sleep(3600) + if handler.is_alive(): + logger.debug('Starting hourly routine.') + if datetime.now().hour == 9: + logger.info('Finding daily puzzle') + # Daily puzzle + p = get_daily_puzzle() + if p is not None: + id, fen, moves = p + png, image_fen, side = get_board_image(fen, moves.split()[0:1]) + text = texts['daily'].format(side_en=lang['en'][side], side_es=lang['es'][side]) + logger.info(f'Sending daily puzzle: {id}') + media = mastodon.media_post(png, mime_type='image/png', description=image_fen) + status = mastodon.status_post(text, media_ids=media, visibility='unlisted') + logger.info(f"Posted {status.url}") + update_daily_puzzle(id, status.id) + else: + logger.warning('Daily puzzle not generated.') + else: + # Post challenge one third of hours + if random.random() < 1/3: + logger.info('Looking for a challenge') + p = find_challenge() + if p is not None: + logger.info('Posting challenge') + id, fen, moves, user, points = p + post_fake_daily_puzzle(id, fen, moves, user, points) + else: + logger.info('No challenge found.') + else: + logger.warning('Handler is dead.') + except MastodonError as ex: + logger.error(f"Error listening to stream: {str(ex)}") + time.sleep(30) + logger.info("Reconnecting...") diff --git a/chessutils.py b/chessutils.py new file mode 100644 index 0000000..709a51f --- /dev/null +++ b/chessutils.py @@ -0,0 +1,45 @@ +import io +import re + +import chess.svg +from svglib.svglib import svg2rlg +from reportlab.graphics import renderPM + + +def is_uci_move(string): + return True if re.fullmatch(r"[a-h][1-8][a-h][1-8][rnbq]?", string.lower()) else False + + +def is_pgn_move(string): + return True if re.fullmatch(r"([RNBQK]?[a-h]?[1-8]?x?[a-h][1-8](=[RNBQ])?[+#]?)|(O-O(-O)?[+#]?)", string) else False + + +def get_board_image(fen, moves): + board = chess.Board(fen) + first_turn = board.turn + last = None + for m in moves: + if m != 'END': + last = board.push_uci(m) + svg = chess.svg.board(board, orientation=chess.BLACK if first_turn == chess.WHITE else chess.WHITE, lastmove=last) + bio = io.BytesIO() + renderPM.drawToFile(svg2rlg(io.StringIO(svg)), bio, fmt="PNG") + bio.seek(0) + return bio.read(), board.fen(), 'black' if board.turn == chess.BLACK else 'white' + + +def play_uci_moves(fen, moves): + board = chess.Board(fen) + for m in moves: + board.push_uci(m) + return board.fen() + + +def pgn2uci(fen, pgn): + board = chess.Board(fen) + try: + move = board.push_san(pgn) + return move.uci() + except ValueError: + return None + diff --git a/dbsetup.py b/dbsetup.py new file mode 100644 index 0000000..6580552 --- /dev/null +++ b/dbsetup.py @@ -0,0 +1,111 @@ +import csv +import json +import os +import sqlite3 +import sys + +if len(sys.argv) != 4 or not sys.argv[3].isdigit(): + print('Usage: python dbsetup.py lichess_file db_file min_popularity') + sys.exit(1) +lichess_file = sys.argv[1] +db_file = sys.argv[2] +min_popularity = int(sys.argv[3]) +if os.path.isfile(db_file): + print(f"File already exists: {db_file}") + sys.exit(2) +with sqlite3.connect(db_file) as conn: + cursor = conn.cursor() + cursor.execute('''CREATE TABLE puzzles + ( + id TEXT PRIMARY KEY, + fen TEXT NOT NULL, + moves TEXT NOT NULL, + rating INTEGER NOT NULL, + tags TEXT + ) + ''') + cursor.execute('''create table users + ( + username TEXT + constraint users_pk + primary key, + hidden integer default 1 not null, + level TEXT + ) + ''') + cursor.execute('''create table played + ( + id INTEGER + primary key autoincrement, + puzzle_id TEXT not null + constraint played_puzzles_id_fk + references puzzles, + log_date TEXT, + status_id TEXT, + user TEXT + constraint played_users_username_fk + references users, + daily INTEGER default 0 not null, + date_played TEXT not null + ); + ''') + cursor.execute('''create table moves +( + puzzle_id TEXT not null + constraint moves_puzzles_id_fk + references puzzles, + user TEXT not null + constraint moves_users_username_fk + references users, + order_num INTEGER not null, + tried TEXT, + moves_so_far TEXT not null, + good INTEGER not null, + completed integer default 0 not null, + status_id TEXT not null, + date_played TEXT not null +); + ''') + cursor.execute('''create table completed +( + puzzle_id TEXT not null + constraint completed_puzzles_id_fk + references puzzles, + user TEXT not null + constraint completed_users_username_fk + references users, + date_finished TEXT not null, + constraint completed_pk + primary key (puzzle_id, user) +); + ''') + cursor.close() + print('Tables created.') + with open(lichess_file) as f: + reader = csv.DictReader(f, delimiter=',', quoting=csv.QUOTE_NONE) + count = 0 + print('Loading puzzles... It will take a long time. Be patient...') + for lin in reader: + popularity = int(lin['Popularity']) + if popularity >= min_popularity: + cursor = conn.cursor() + cursor.execute('INSERT INTO puzzles (id, fen, moves, rating, tags) values (?,?,?,?,?)', + (lin['PuzzleId'], lin['FEN'], lin['Moves'], int(lin['Rating']), + f"{lin['Themes']} {lin['OpeningTags']}")) + conn.commit() + cursor.close() + count += 1 + print(f"Loaded {count} puzzles.") + # Generate tag list + tags = set() + cursor = conn.cursor() + cursor.execute('SELECT tags FROM puzzles') + for t in cursor.fetchall(): + # Discarding opening tags (including underscore character) + tags.update([tt for tt in t[0].split() if '_' not in tt]) + cursor.close() + tags = list(tags) + tags.sort() + with open('tags.json', 'w') as f: + json.dump(tags, f) + print(f"Written tag file with {len(tags)} tags.") diff --git a/dbutils.py b/dbutils.py new file mode 100644 index 0000000..0d20660 --- /dev/null +++ b/dbutils.py @@ -0,0 +1,303 @@ +import os +import sqlite3 +from random import randint + + +def get_private_puzzle(user=None, tag=None): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + cur = con.cursor() + cur.execute('SELECT level FROM users WHERE username = ?', (user, )) + res = cur.fetchone() + if res is not None: + level = res[0] + else: + cur.execute('INSERT INTO users (username) VALUES (?)', (user,)) + level = None + condition = '' + if level is not None: + if level == 'easy': + condition = ' rating < 1000 ' + elif level == 'medium': + condition = ' rating between 1000 and 2000 ' + elif level == 'hard': + condition = ' rating > 2000 ' + if tag is not None: + condition += f"{' AND ' if condition else ''} tags like ?" + sel = 'SELECT COUNT(*) FROM puzzles WHERE id NOT IN (SELECT puzzle_id FROM played WHERE daily = 1 OR user = ?)' + if condition: + sel += f' AND {condition}' + cur.execute(sel, (user, f"%{tag}%", ) if tag is not None else (user, )) + total = cur.fetchone()[0] + if total > 0: + sel = ''' + SELECT id, fen, moves  + FROM puzzles  + WHERE id NOT IN (SELECT puzzle_id FROM played WHERE daily = 1 OR user = ?) + ''' + if condition: + sel += f' AND {condition}' + sel += f' LIMIT 1 OFFSET {randint(0, total-1)}' + cur.execute(sel, (user, f"%{tag}%", ) if tag is not None else (user, )) + p = cur.fetchone() + if p is not None: + cur.execute("INSERT INTO played (puzzle_id, user, date_played) VALUES (?, ?, datetime('now'))", (p[0], user)) + con.commit() + return p + else: + return None + + +def update_private_puzzle(puzzle_id, user, status_id): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + cur = con.cursor() + cur.execute("UPDATE played SET status_id = ? WHERE puzzle_id = ? AND user = ?", (str(status_id), puzzle_id, user)) + con.commit() + + +def get_daily_puzzle(): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + cur = con.cursor() + cur.execute("SELECT count(*) FROM played WHERE daily = 1 AND log_date = date('now')") + if cur.fetchone()[0] == 0: + cur.execute('SELECT count(*) FROM puzzles WHERE id NOT IN (SELECT puzzle_id FROM played WHERE daily = 1)') + total = cur.fetchone()[0] + if total > 0: + cur.execute('SELECT id, fen, moves FROM puzzles WHERE id NOT IN (SELECT puzzle_id FROM played WHERE daily = 1) LIMIT 1 OFFSET ?', (randint(0, total-1 ), )) + p = cur.fetchone() + if p is not None: + cur.execute("INSERT INTO played (puzzle_id, daily, log_date, date_played) VALUES (?, 1, date('now'), datetime('now'))", (p[0], )) + con.commit() + return p + else: + return None + else: + return None + + +def insert_fake_daily_puzzle(puzzle_id): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + cur = con.cursor() + cur.execute("SELECT 1 FROM played WHERE daily = 1 AND puzzle_id = ?", (puzzle_id, )) + if cur.fetchone() is None: + cur.execute("INSERT INTO played (puzzle_id, daily, date_played) VALUES (?, 1, datetime('now'))", (puzzle_id, )) + con.commit() + return True + else: + return False + + +def update_daily_puzzle(puzzle_id, status_id): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + cur = con.cursor() + cur.execute("UPDATE played SET status_id = ? WHERE puzzle_id = ? AND daily = 1", (str(status_id), puzzle_id)) + con.commit() + + +def find_played(status_id, user): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + cur = con.cursor() + cur.execute(''' + SELECT puzzle_id + FROM played + WHERE status_id = ? + AND (user = ? OR daily = 1) + ''', (str(status_id), user, )) + res = cur.fetchone() + if res is not None: + cur.execute(''' + SELECT id, fen, moves, rating, tags, 0, substr(moves, 1, instr(moves, ' ') - 1), 0 + FROM puzzles + WHERE id = ? + ''', res) + else: + cur.execute(''' + SELECT p.id, p.fen, p.moves, p.rating, p.tags, m.order_num, m.moves_so_far, m.completed + FROM moves m + JOIN puzzles p ON m.puzzle_id = p.id + WHERE status_id = ? + AND user = ? + ORDER BY m.order_num DESC LIMIT 1 + ''', (status_id, user, )) + return cur.fetchone() + + +def log_move(puzzle_id, tried, moves_so_far, order_num, user, status_id, good, completed): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + cur = con.cursor() + cur.execute(''' + SELECT 1 + FROM users + WHERE username = ? + ''', (user, )) + if cur.fetchone() is None: + cur.execute('INSERT INTO users (username) VALUES (?)', (user, )) + cur.execute(''' + INSERT INTO moves (puzzle_id, tried, moves_so_far, order_num, user, status_id, good, completed, date_played) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + ''', (puzzle_id, tried, ' '.join(moves_so_far), order_num, user, str(status_id), good, completed)) + con.commit() + + +def get_puzzle_results(puzzle_id, user): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + points = None + fails = None + total = None + average = None + hidden = None + cur = con.cursor() + cur.execute(''' + SELECT max(p.rating), max(m.completed), count(*) - sum(m.good) + FROM moves m  + JOIN puzzles p ON p.id = m.puzzle_id + WHERE m.puzzle_id = ? + AND m.user = ? + GROUP BY m.puzzle_id, m.user; + ''', (puzzle_id, user,)) + res = cur.fetchone() + if res is not None: + points, completed, fails = res + if completed == 1 and fails == 0: + # Success! + cur.execute(''' + INSERT INTO completed + (puzzle_id, date_finished, user) + values (?, datetime('now'), ?) + ''', (puzzle_id, user, )) + con.commit() + else: + points = 0 + cur.execute(''' + SELECT count(*), avg(p.rating) + FROM completed c + JOIN puzzles p ON p.id = c.puzzle_id  + WHERE c.user = ? + GROUP BY user + ''', (user, )) + res = cur.fetchone() + if res is not None: + total, average = res + cur.execute(''' + SELECT hidden + FROM users + WHERE username = ?  + ''', (user, )) + res = cur.fetchone() + if res is not None: + hidden = res[0] + cur.close() + return points, fails, total, round(average), hidden + + +def get_puzzle_completed_points(puzzle_id, user): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + cur = con.cursor() + cur.execute(''' + SELECT p.rating + FROM puzzles p  + JOIN completed c ON c.puzzle_id = p.id + WHERE c.user = ? + AND c.puzzle_id = ? + ''', (user, puzzle_id, )) + res = cur.fetchone() + return res[0] if res is not None else None + + +def set_user_level(user, level): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + new_level = None if level == 'all' else level + cur = con.cursor() + cur.execute(''' + SELECT 1 + FROM users + WHERE username = ? + ''', (user, )) + if cur.fetchone() is None: + cur.execute('INSERT INTO users (username, level) VALUES (?, ?)', (user, new_level, )) + else: + cur.execute('UPDATE users SET level = ? WHERE username = ?', (new_level, user, )) + con.commit() + cur.close() + + +def set_user_hidden(user, hidden): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + cur = con.cursor() + cur.execute(''' + SELECT 1 + FROM users + WHERE username = ? + ''', (user, )) + if cur.fetchone() is None: + cur.execute('INSERT INTO users (username, hidden) VALUES (?, ?)', (user, hidden, )) + else: + cur.execute('UPDATE users SET hidden = ? WHERE username = ?', (hidden, user, )) + con.commit() + cur.close() + + +def delete_user(user): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + cur = con.cursor() + cur.execute('DELETE FROM completed WHERE user = ?', (user, )) + cur.execute('DELETE FROM played WHERE user = ?', (user, )) + cur.execute('DELETE FROM moves WHERE user = ?', (user, )) + cur.execute('DELETE FROM users WHERE username = ?', (user, )) + con.commit() + cur.close() + + +def get_user_stats(user): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + total = None + solved = None + average = None + ranking = None + cur = con.cursor() + cur.execute(''' + SELECT count(*), avg(p.rating) + FROM completed c + JOIN puzzles p ON p.id = c.puzzle_id  + WHERE c.user = ? + GROUP BY user + ''', (user, )) + res = cur.fetchone() + if res is not None: + solved, average = res + cur.execute(''' + SELECT COUNT(DISTINCT puzzle_id) + FROM moves + WHERE user = ? + ''', (user, )) + res = cur.fetchone() + if res is not None: + total = res[0] + cur.execute(''' + SELECT COUNT(*) + FROM (SELECT 1 + FROM completed c + GROUP BY user + HAVING COUNT(*) > ?) + ''', (solved, )) + res = cur.fetchone() + if res is not None: + ranking = res[0] + 1 + return total, solved, round(average), ranking + + +def find_challenge(): + with sqlite3.connect(os.getenv('SQLITE_DB')) as con: + cur = con.cursor() + # Find the last puzzle solved by only one not hidden user that wasn't daily + cur.execute(''' + SELECT p.id, p.fen, p.moves, c.user, p.rating + FROM completed c + JOIN puzzles p ON p.id = c.puzzle_id + JOIN users u ON u.username = c.user + WHERE u.hidden = 0 + AND p.id NOT IN + (SELECT y.puzzle_id FROM played y WHERE y.daily = 1 OR y.user != c.user) + ORDER BY c.date_finished DESC + LIMIT 1 + ''') + return cur.fetchone() diff --git a/img/screenshot.jpg b/img/screenshot.jpg new file mode 100644 index 0000000..28bd972 Binary files /dev/null and b/img/screenshot.jpg differ diff --git a/lang/en.yml b/lang/en.yml new file mode 100644 index 0000000..734fef6 --- /dev/null +++ b/lang/en.yml @@ -0,0 +1,96 @@ +white: white +black: black +commands: + new: new + resolve: resolve + results: results + hide: hide + show: show + help: help + hard: hard + medium: medium + easy: easy + all: all + tags: tags + delete: delete + confirm: confirm +messages: + too_many_commands: Sorry, I can't understand more than one command at a time! + bad_command: Sorry, I can't understand what you're saying. Send "help" for available commands. + public_rejected: Sorry, I respond only to private messages (DM), out of respect for other users. Send "help" for further instructions. + already_completed: This exercise is already completed. Send "new" if you want more. + correct: GOOD! Continue with the next move. + incorrect: It's not the best move. + continue_white: White plays + continue_black: Black plays + hidden: Your results are private. If you want to show then to other users, send "show". + public: Your results are public. If you want to hide them to other users, send "hide". + set_public: From now on, your results are public and will be shared with other users. Send "hide" at any time to disable it. + set_hidden: From now on, your results are private and will not be shared with other users. Send "show" at any time to reactivate it. + level_easy: You will receive exercises of easy level. Send "medium", "hard" or "all" to change it. + level_medium: You will receive exercises of medium level. Send "easy", "hard" or "all" to change it. + level_hard: You will receive exercises of hard level. Send "easy", "medium" or "all" to change it. + level_all: You will receive exercises of all levels. Send "easy", "medium" or "hard" to change it. + results: | + You have attempted a total of {total} puzzles. + You have solved {solved} puzzles without errors, with an average of {average} points. + ranking: You are ranked {ranking} among the users with the most puzzles solved. + win: | + GREAT! You completed the puzzle without fails. + Tags: {tags} + You got {points} points. + You solved {total} exercises without errors with an average of {average} points. + no_win: | + You completed the puzzle with fails: {wrong} + Tags: {tags} + You solved {total} exercises without errors with an average of {average} points. + finished: | + The puzzle is finished. The best move was {move}. + Tags: {tags} + solved_move: The best move was {move}. Continue. + new_white: Choose the best move for white. + new_black: Choose the best move for black. + deleting: ALL YOUR DATA AND SCORES WILL BE DELETED! Send "confirm" to complete the deletion. + deleted: All your data has been deleted from the ChessPuzzleBot server. + help_text: + - > + Answer a puzzle with the next move that you consider the best.  + You can use PGN notation (such as Kh8) or UCI (such as h7h8).  + Use the correct capitalization, according to the rules of the notation. + I will reply to you if you got it right or wrong, and you can continue with the following moves. +  + SEND ONLY ONE MOVE AT A TIME! If you send several, I will only take into account the last one. +  + NOTE! I do not respond to public mentions, so as not to clutter up other users' timelines. + When you send me your moves, use the private message (DM), so as not to spoil the solutions. + For the same reason, I will always respond to you privately.  +  + If you do not know how to continue a puzzle, at any time you can ask me for the solution,  + by sending the answer "resolve". I will give you the next move, which will count as a fail, + and you can continue the puzzle if you wish.  + - >  + You can play the daily puzzle that is published every morning (Spanish time),  + or ask me privately for all the puzzles you want (send me the command "new").  +  + You can ask for puzzles by theme.  + To do this, instead of "new", send one of the tags supported by Lichess  + (corresponding to game phases, types of strategy, etc.).  + Send "tags" to get the complete list.  + You must write the tags exactly as they appear, but the capitalization does not matter.  +  + For private puzzles you can decide the level of difficulty. Send "hard", "medium" or "easy"  + to set that level for your next puzzles. To receive any random level (default option)  + write "all".  +  + Each puzzle has a score, which Lichess has assigned to it according to the ELO level of mastery theoretically  + required to solve it. When you solve a puzzle without mistakes, its score will be added to your average.  + At any time, you can ask me for your stats by sending the "results" command.  +  + As you've seen, I'm a very privacy-respecting bot. I don't keep any data regarding your profile  + or any other type. I only record your user name and the results of the puzzles you've attempted.  + By default, I won't publish any of your results. It's all between you and me.  + If you want to share your achievements and appear publicly in the top player rankings  + send the "show" command. To deactivate it, send "hide" at any time.  +  + You can request the deletion of all your results by sending "delete",  + and I'll forget about you until you talk to me again. diff --git a/lang/es.yml b/lang/es.yml new file mode 100644 index 0000000..956c0d9 --- /dev/null +++ b/lang/es.yml @@ -0,0 +1,97 @@ +white: blancas +black: negras +commands: + nuevo: new + resolver: resolve + resultados: results + ocultar: hide + mostrar: show + ayuda: help + difícil: hard + dificil: hard + medio: medium + fácil: easy + facil: easy + todos: all + etiquetas: tags + borrar: delete + confirmar: confirm +messages: + too_many_commands: Lo siento, no puedo entender más de un comando a la vez. + bad_command: Lo siento, no puedo entender lo que me dices. Envía "ayuda" para conocer los comandos disponibles. + public_rejected: Lo siento, solo respondo a mensajes privados (DM), por respeto a otros usuarios. Envía "ayuda" para más instrucciones. + already_completed: Este ejercicio ya está completado. Envía "nuevo" si deseas más. + correct: ¡CORRECTO! Continúa con el siguiente movimiento. + incorrect: No es el mejor movimiento. Inténtalo de nuevo. + continue_white: Juegan blancas. + continue_black: Juegan negras. + hidden: Tus resultados son privados. Si deseas mostrarlos a otros usuarios, envía "mostrar". + public: Tus resultados son públicos. Si deseas ocultarlos a otros usuarios, envía "ocultar". + set_public: A partir de ahora, tus resultados son públicos y se compartirán con otros usuarios. Envía "ocultar" en cualquier momento para desactivarlo. + set_hidden: A partir de ahora, tus resultados son privados y no se compartirán con otros usuarios. Envía "mostrar" en cualquier momento para reactivarlo. + level_easy: Recibirás ejercicios de nivel fácil. Envía "medio", "difícil" o "todos" para cambiarlo. + level_medium: Recibirás ejercicios de nivel medio. Envía "fácil", "difícil" o "todos" para cambiarlo. + level_hard: Recibirás ejercicios de nivel difícil. Envía "fácil", "medio" o "todos" para cambiarlo. + level_all: Recibirás ejercicios de todos los niveles. Envía "fácil", "medio" o "difícil" para cambiarlo. + results: | + Has intentado un total de {total} ejercicios. + Has resuelto {solved} ejercicios sin fallos, con una media de {average} puntos. + ranking: Ocupas el puesto {ranking} entre los usuarios con más ejercicios resueltos. + win: |  + ¡GENIAL! Has completado el ejercicio sin fallos. + Etiquetas: {tags} + Has obtenido {points} puntos. + Has resuelto {total} ejercicios sin fallos con una media de {average} puntos. + no_win: |  + Has completado el ejercicio con fallos: {wrong} + Etiquetas: {tags} + Has resuelto {total} ejercicios sin fallos con una media de {average} puntos. + finished: | + El ejercicio ha terminado. El movimiento correcto era {move}. + Etiquetas: {tags} + solved_move: El movimiento correcto era {move}. Continúa. + new_white: Elige el mejor movimiento de las blancas. + new_black: Elige el mejor movimiento de las negras. + deleting: ¡TODOS TUS DATOS Y PUNTUACIONES SERÁN BORRADAS! Envía "confirmar" para completar el borrado. + deleted: Todos tus datos han sido borrados del servidor de ChessPuzzleBot. + help_text: + - >  + Responde a un ejercicio con el siguiente movimiento que consideres el mejor.  + Puedes usar notación PGN (del tipo Kh8) o UCI (del tipo h7h8). Usa las mayúsculas correctamente, según las reglas de la notación. + Te responderé si has acertado o no, y podrás continuar con los siguientes movimientos. +  + ¡ENVÍA SOLO UN MOVIMIENTO A LA VEZ! Si envías varios, solo tendré en cuenta el último de ellos. +  + ¡OJO! No respondo a menciones públicas, para no saturar las líneas de tiempo de otros usuarios. + Cuando me envíes tus movimientos, utiliza el mensaje privado (DM), para no destripar las soluciones. + Por la misma razón, yo te respondere siempre en privado.  +  + Si no sabes continuar un ejercicio, en cualquier momento puedes pedirme la solución,  + enviando la respuesta "resolver". Yo te daré el siguiente movimiento, que contará como un fallo, + y podrás continuar el ejercicio si lo deseas. + - >  + Puedes jugar el ejercicio diario que se publica todas las mañanas (hora española),  + o bien pedirme en privado todos los ejercicios que desees (envíame el comando "nuevo"). +  + Puedes pedir ejercicios por temáticas.  + Para ello, en lugar de "nuevo", envía una de las etiquetas soportadas por Lichess  + (correspondientes a fases del juego, tipos de estrategia, etc.).  + Envía "etiquetas" para obtener la lista completa.  + Debes escribir las etiquetas exactamente como aparecen, pero no importan las mayúsculas. +  + Para los ejercicios privados puedes decidir el nivel de dificultad. Envía "difícil", "medio" o "fácil"  + para establecer ese nivel en tus próximos ejercicios. Para recibir cualquier nivel al azar (opción por defecto) + escribe "todos". +  + Cada ejercicio tiene una puntuación, que le ha asignado Lichess según el nivel ELO de maestría teóricamente  + necesaria para resolverlo. Cuando resuelvas un ejercicio sin fallos, su puntuación se agregará a tu media.  + En cualquier momento, puedes pedirme tus estadísticas enviando el comando "resultados".  +  + Como has visto, soy un bot muy respetuoso con la privacidad. No guardo ningún dato referente a tu perfil  + ni de ningún otro tipo. Solo registro tu nick de usuario y los resultados de los ejercicios que has intentado.  + Por defecto, no publicaré ninguno de tus resultados. Todo quedará entre tú y yo.  + Si deseas compartir tus logros y aparecer públicamente en la clasificación de los mejores jugadores  + envía el comando "mostrar". Para desactivarlo, envía "ocultar" en cualquier momento. +  + Puedes solicitar el borrado de todos tus resultados enviando "borrar", y me olvidaré de ti hasta que vuelvas + a hablar conmigo. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e6c8d20 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Mastodon.py~=1.8.1 +python-dotenv~=1.0.1 +svglib~=1.5.1 +rlPyCairo~=0.3.0 +reportlab~=4.2.5 +chess~=1.11.1 +PyYAML~=6.0.2 \ No newline at end of file diff --git a/texts.yml b/texts.yml new file mode 100644 index 0000000..a6f6ae3 --- /dev/null +++ b/texts.yml @@ -0,0 +1,20 @@ +daily: | + 🇪🇸 Ejercicio de #ajedrez del día + Juegan {side_es}.  + Encuentra el mejor movimiento.  + Envía "ayuda" para más información.  + + 🇬🇧 Daily #chess puzzle + {side_en} to play. + Find the best move. + Send "help" for more information. +shared: | + 🇪🇸 @{user} ha obtenido {points} puntos con este ejercicio. ¿Te atreves a intentarlo? + Juegan {side_es}.  + Encuentra el mejor movimiento.  + Envía "ayuda" para más información.  +  + 🇬🇧 @{user} has earned {points} points with this puzzle. Do you dare to try it? + {side_en} to play. + Find the best move. + Send "help" for more information.