-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #46 from tvdhout/v3.0
Merge v3.0 to main
- Loading branch information
Showing
44 changed files
with
1,255 additions
and
1,347 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') |
Oops, something went wrong.