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