Skip to content

Commit

Permalink
Merge pull request #46 from tvdhout/v3.0
Browse files Browse the repository at this point in the history
Merge v3.0 to main
  • Loading branch information
tvdhout authored Aug 16, 2022
2 parents 5d8ac6a + 430ee40 commit 90e8962
Show file tree
Hide file tree
Showing 44 changed files with 1,255 additions and 1,347 deletions.
64 changes: 27 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,33 @@
# Lichess discord bot
<div style="display: flex; height: 100px; gap: 20px; align-items: center">
<img src="media/logo-green-render.png#gh-dark-mode-only" style="height:100px">
<img src="media/logo-green-render-dark.png#gh-light-mode-only" style="height:100px">
<h1 style="height: 100%; justify-content: center">Lichess Discord Bot</h1>
</div>

<br>

[![Bot status widget](https://top.gg/api/widget/status/707287095911120968.svg)](https://top.gg/bot/707287095911120968)
[![Discord Bots](https://top.gg/api/widget/servers/707287095911120968.svg)](https://top.gg/bot/707287095911120968)

[Click here to invite the bot to your server!](https://discord.com/api/oauth2/authorize?client_id=707287095911120968&permissions=116800&scope=bot)

## Description
This bot integrates with the lichess.org chess website. The bot can show chess puzzles which can be solved right there in the channel! It can also retrieve ELO rankings for users, show user profiles, and connect your discord account to your lichess account for enhanced functionality!

## Default prefix
`-` (customizable)

## COMMANDS
* `-help` or `-commands` → show list of commands
* `-lichessprefix [new prefix]` → Set a custom command prefix for your server
* `-about` → show information about the bot
* `-connect [lichess username]` → connect your Discord profile with your Lichess account.
* `-disconnect` → disconnect your Discord profile from a connected Lichess account
* `-rating [username | user url]` → show all chess ratings. When connected with `-connect` you can use this command without giving a username.
* `-rating [username | user url] [gamemode]` → retrieve the ratings for a user for a specific gamemode
* `-puzzle` → show a random lichess puzzle, or one near your puzzle rating if your Lichess account is connected using `-connect`
* `-puzzle [id]` → show a specific chess puzzle
* `-puzzle [rating1]-[rating2]` → show a random chess puzzle with a difficulty rating in a range
* `-answer [move]` → give an answer to the most recent puzzle shown in the channel
* `-bestmove` → ask for the answer for the most recent puzzle shown in the channel. If there are more steps to the puzzle, the user can continue from the next step
* `-profile [username]` → show a lichess user profile. When connected with `-connect` you can use this command without giving a username.

## Help / Contact / Issues / Requests / Collaboration
<a href="https://discord.com/api/oauth2/authorize?client_id=707287095911120968&permissions=309237696512&scope=bot%20applications.commands">
<img src="media/invite.svg" style="width: 250px" alt="Invite to server button">
</a>

## Lichess puzzles on Discord!
The Lichess Bot enables you to solve millions of Lichess puzzles in the Discord chat (try the different `/puzzle` commands). You can connect it to your Lichess account to get puzzles near your rating, and look up anyone's Lichess ratings and profile. Interact with the bot exclusively through slash commands.

## Slash commands
* `/about` → show information about the bot
* `/connect` → connect your Lichess account to your Discord profile
* `/disconnect` → disconnect your Lichess account from your Discord profile (and delete your data)
* `/puzzle random` → show a random lichess puzzle, or one near your puzzle rating if your Lichess account is connected using `/connect`
* `/puzzle id` → show a specific chess puzzle by ID
* `/puzzle rating` → show a random chess puzzle with a difficulty rating in your specified range
* `/puzzle theme [ignore_rating]` → show a random chess puzzle with a specific popular theme
* `/answer` → give an answer to the most recent puzzle shown in the channel
* `/rating [Lichess username]` → show the Lichess ratings of your linked account (or someone else's, with the optional argument)
* `/profile [Lichess username]` → show the Lichess profile of your linked account (or someone else's, with the optional argument)

## Help / Contact / Issues / Suggestions
Questions, issues and requests can be posted as an issue in this repository, or posted in the [discord support server](https://discord.gg/KdpvMD72CV)

## Screenshots
![Puzzle command screenshot](/media/puzzle-example.png)

![Answer command screenshot](/media/answer-example.gif)

![Bestmove command screenshot](/media/bestmove-example.png)

![Rating command screenshot](/media/rating-example.png)

![Connect command screenshot](/media/connect-example.png)

![Profile command screenshot](/media/profile-example.png)
105 changes: 105 additions & 0 deletions bot/LichessBot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""
Invite the bot to your server with the following URL
https://discord.com/api/oauth2/authorize?client_id=707287095911120968&permissions=309237696512&scope=bot%20applications.commands
"""
import os
import sys
import logging
from logging import handlers
from functools import cached_property

import discord
from aiohttp import ClientSession
from discord.ext import commands
from discord.ext.commands import Context
from sqlalchemy.orm import sessionmaker
from dotenv import load_dotenv

from database import engine, Puzzle, ChannelPuzzle


class LichessBot(commands.Bot):
def __init__(self, development: bool, **kwargs):
super().__init__(**kwargs)
self.__session: ClientSession | None = None
self.synced = False
self.development = development
self.logger = self._set_logger()
self.Session = sessionmaker(bind=engine)

async def setup_hook(self):
self.logger.info(f"Running setup_hook for {'DEVELOPMENT' if self.development else 'PRODUCTION'}")
self.__session = ClientSession()
# Load command cogs
self.logger.info("Loading command cogs...")
extensions = ['cogs.puzzle', 'cogs.answer', 'cogs.connect', 'cogs.rating', 'cogs.profile', 'cogs.about']
for extension in extensions:
await client.load_extension(extension)
if not self.development:
await client.load_extension('cogs.top_gg')
self.logger.info("Finished loading extension cogs")

if not self.synced:
self.logger.info('Syncing commands')
await client.tree.sync(guild=discord.Object(id=707286841577177140) if self.development else None)
self.synced = True

async def close(self):
await super().close()
await self.__session.close()

async def on_ready(self):
await self.change_presence(activity=discord.Activity(type=discord.ActivityType.listening,
name='Slash commands'))
self.logger.info(f"Logged in as {self.user}")
self.logger.info(f"Bot id: {self.user.id}")

async def on_command_error(self, context: Context, exception: Exception):
if isinstance(exception, (commands.CommandNotFound, commands.NoPrivateMessage)):
return
self.logger.exception(f"{type(exception).__name__}: {exception}")
raise exception

async def on_raw_thread_delete(self, payload: discord.RawThreadDeleteEvent):
with self.Session() as session:
session.query(ChannelPuzzle).filter(ChannelPuzzle.channel_id == payload.thread_id).delete()
session.commit()

@cached_property
def total_nr_puzzles(self) -> int:
with self.Session() as session:
self.logger.info('Computing nr of puzzles...')
return session.query(Puzzle).count()

def _set_logger(self) -> logging.getLoggerClass():
logger = logging.getLogger('discord')
logger.setLevel(logging.INFO)
logging.getLogger('discord.http').setLevel(logging.INFO)

file_handler = logging.handlers.RotatingFileHandler(
filename='discord.log',
encoding='utf-8',
maxBytes=32 * 1024 * 1024, # 32 MiB
backupCount=5, # Rotate through 5 files
)
dt_fmt = '%Y-%m-%d %H:%M:%S'
formatter = logging.Formatter('[{asctime}] [{levelname}] {name}: {message}', dt_fmt, style='{')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
if self.development or sys.argv[-1] == 'DEBUG':
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
return logger


if __name__ == '__main__':
load_dotenv()
development = sys.argv[-1] == 'DEVELOPMENT'

client: LichessBot = LichessBot(development=development,
command_prefix='%lb',
application_id=os.getenv(f'{"DEV_" if development else ""}DISCORD_APPLICATION_ID'),
intents=discord.Intents.default())

client.run(token=os.getenv('DEV_DISCORD_TOKEN' if client.development else 'DISCORD_TOKEN'), log_handler=None)
54 changes: 54 additions & 0 deletions bot/cogs/about.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import re

import requests
import discord
from discord import app_commands
from discord.utils import MISSING
from discord.ext import commands

from LichessBot import LichessBot


class About(commands.Cog):
def __init__(self, client: LichessBot):
self.client = client

@app_commands.command(
name='about',
description='About this bot',
)
async def about(self, interaction: discord.Interaction):
try:
database_site = requests.get('https://database.lichess.org/#puzzles').content.decode('utf-8')
n_puzzles = re.search(r'<strong>([\d,]+)</strong>', database_site).group(1)
except (requests.RequestException, AttributeError):
n_puzzles = 'about 3 million'

embed = discord.Embed(title='Lichess Discord Bot', color=0xdbd7ca,
url="https://github.com/tvdhout/lichess-discord-bot")
embed.set_footer(text="Made by Thijs#9356",
icon_url="https://cdn.discordapp.com/avatars/289163010835087360/"
"f54134557a6e3097fe3ffb8f6ba0cb8c.webp?size=128")
embed.add_field(name='🤖 About this bot', value=f'The Lichess Bot enables you to solve Lichess\' {n_puzzles} '
f'puzzles in the Discord chat (try the different `/puzzle` '
f'commands). You can connect it to your Lichess account to '
f'get puzzles near your rating, and look up anyone\'s '
f'Lichess ratings and profile. The bot has recently been '
f'updated to interact exclusively through slash commands.',
inline=False)
embed.add_field(name='🌐 Open source', value='The source code for this bot is publicly available. You can find '
'it on [GitHub](https://github.com/tvdhout/lichess-discord-bot).',
inline=False)
embed.add_field(name='👍 Top.gg', value='If you enjoy the bot, you can upvote it, rate it, and leave a comment '
'on [its top.gg page](https://top.gg/bot/707287095911120968). Top.gg is '
'a website that lists many public Discord bots. Thanks!',
inline=False)
embed.add_field(name='🦠 Support', value='Found a bug? Need help? Have some suggestions? Reach out to me on '
'the support [discord server](https://discord.gg/KdpvMD72CV)!',
inline=False)
await interaction.response.send_message(embed=embed)


async def setup(client: LichessBot):
await client.add_cog(About(client), guild=discord.Object(id=707286841577177140) if client.development else MISSING)
client.logger.info('Sucessfully added cog: About')
92 changes: 92 additions & 0 deletions bot/cogs/answer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import re

import discord
from discord import app_commands
from discord.utils import MISSING
from discord.ext import commands
import chess

from LichessBot import LichessBot
from database import ChannelPuzzle
from views import UpdateBoardView, WrongAnswerView


class Answer(commands.Cog):
def __init__(self, client: LichessBot):
self.client = client

@app_commands.command(
name='answer',
description='Give the best move in the current position (requires an active `/puzzle`)',
)
@app_commands.describe(answer='The best move in this position in SAN or UCI notation')
async def answer(self, interaction: discord.Interaction, answer: str):
with self.client.Session() as session:
c_puzzle_query = session.query(ChannelPuzzle).filter(ChannelPuzzle.channel_id == interaction.channel_id)
c_puzzle = c_puzzle_query.first()
if c_puzzle is None:
return await interaction.response.send_message('There is no active puzzle in this channel! Start a '
'puzzle with any of the `/puzzle` commands')

moves = c_puzzle.moves
board = chess.Board(c_puzzle.fen)
correct_uci = moves.pop(0)
correct_san = board.san(board.parse_uci(correct_uci))
stripped_correct_san = re.sub(r'[|#+x]', '', correct_san.lower())
stripped_answer = re.sub(r'[|#+x]', '', answer.lower())

def answer_is_mate(answer: str) -> bool:
try:
board.push_san(answer)
except ValueError:
try:
board.push_uci(answer)
except ValueError:
return False
if board.is_game_over() and not board.is_stalemate():
return True
board.pop()
return False

embed = discord.Embed(title=f'Your answer is...')

if stripped_answer in [correct_uci, stripped_correct_san]:
embed.colour = 0x7ccc74
if len(moves) == 0: # Last step of the puzzle
embed.add_field(name="Correct!", value=f"Yes! The best move was {correct_san} (or {correct_uci}). "
f"You completed the puzzle! (difficulty rating "
f"{c_puzzle.puzzle.rating})")
await interaction.response.send_message(embed=embed)
session.delete(c_puzzle)
else: # Not the last step of the puzzle
board.push_uci(correct_uci)
reply_uci = moves.pop(0)
move = board.parse_uci(reply_uci)
reply_san = board.san(move)
board.push(move)

embed.add_field(name='Correct!',
value=f'Yes! The best move was {correct_san} (or {correct_uci}). The opponent '
f'responded with {reply_san}. Now what\'s the best move?')
await interaction.response.send_message(embed=embed, view=UpdateBoardView())

c_puzzle_query.update({'moves': moves, 'fen': board.fen()}) # Update channel puzzle with progress
session.commit()
elif answer_is_mate(answer) or answer_is_mate(answer.capitalize()): # Check if the answer is mate
embed.add_field(name="Correct!", value=f"Yes! {correct_san} (or {correct_uci}) is checkmate! You "
f"completed the puzzle! (difficulty rating "
f"{c_puzzle.puzzle.rating})")
await interaction.response.send_message(embed=embed)
session.delete(c_puzzle)
session.commit()
else: # Incorrect
embed.colour = 0xcc7474
embed.add_field(name="Wrong!",
value=f"{answer} is not the best move :-( Try again or get a hint!")

await interaction.response.send_message(embed=embed, view=WrongAnswerView())


async def setup(client: LichessBot):
await client.add_cog(Answer(client), guild=discord.Object(id=707286841577177140) if client.development else MISSING)
client.logger.info('Sucessfully added cog: Answer')
70 changes: 70 additions & 0 deletions bot/cogs/connect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import os
import hashlib
import random
import string
from base64 import urlsafe_b64encode

import discord
from discord import app_commands
from discord.utils import MISSING
from discord.ext import commands

from LichessBot import LichessBot
from database import APIChallenge, User
from views import ConnectView


class Connect(commands.Cog):
def __init__(self, client: LichessBot):
self.client = client
self.base_url = (f'https://lichess.org/oauth'
f'?response_type=code'
f'&client_id={os.getenv("CONNECT_CLIENT_ID")}'
f'&redirect_uri={os.getenv("CONNECT_REDIRECT_URI")}'
f'&code_challenge_method=S256')

@app_commands.command(
name='connect',
description='Connect your Lichess account to get personalized puzzles',
)
async def connect(self, interaction: discord.Interaction):
with self.client.Session() as session:
user = session.query(User).filter(User.discord_id == interaction.user.id).first()
if user is not None:
return await interaction.response.send_message(f'You already have connected a Lichess account '
f'({user.lichess_username}). To connect a different '
f'account, please first disconnect your current account '
f'using `/disconnect`',
ephemeral=True)
code_verifier = ''.join(random.SystemRandom()
.choice(string.ascii_lowercase + string.digits)
for _ in range(64))
code_challenge = (urlsafe_b64encode(hashlib.sha256(code_verifier.encode('utf-8')).digest())
.decode('utf-8').replace('=', ''))
url = self.base_url + f'&code_challenge={code_challenge}&state={interaction.user.id}'

await interaction.response.send_message(view=ConnectView(url), ephemeral=True)

session.merge(APIChallenge(discord_id=interaction.user.id,
code_verifier=code_verifier))
session.commit()

@app_commands.command(
name='disconnect',
description='Disconnect your Lichess account',
)
async def disconnect(self, interaction: discord.Interaction):
with self.client.Session() as session:
n_deleted: int = session.query(User).filter(User.discord_id == interaction.user.id).delete()
session.commit()
if n_deleted > 0:
await interaction.response.send_message('Lichess account succesfully disconnected and data deleted.',
ephemeral=True)
else:
await interaction.response.send_message('You have not connected a Lichess account.', ephemeral=True)


async def setup(client: LichessBot):
await client.add_cog(Connect(client),
guild=discord.Object(id=707286841577177140) if client.development else MISSING)
client.logger.info('Sucessfully added cog: Connect')
Loading

0 comments on commit 90e8962

Please sign in to comment.