Compare commits

..

No commits in common. "2a52d31295f0e28470fd7deefaf5d96a6315cd04" and "929b9af25406d5edc12dffbeb18d4295fbc462f4" have entirely different histories.

20 changed files with 655 additions and 866 deletions

3
.gitignore vendored
View File

@ -1,5 +1,2 @@
.env
*.pyc
data
__pycache__
venv

View File

@ -1,41 +1,29 @@
BOT Discord de l'authentique Père Fouras sur votre channel discord, avec plus de 300 de ses fameuses énigmes à vous poser.
Pour ajouter ce bot à votre serveur discord :
https://discord.com/api/oauth2/authorize?client_id=1110208055171367014&permissions=274877975552&scope=bot
Ce bot est une refonte totale d'un vieux bot IRC que j'avais développé en C++ en 2016 :
Merci à ChatGPT pour m'avoir aidé à porter mon vieux bot IRC en C++, en un bot discord en python
Lien du vieux bot IRC que j'avais codé en 2016 :
https://git.epicsparrow.com/Anselme/SparrowBot/src/branch/master/app/fourasmodule.cpp
# Pour les utilisateurs
# How to run the bot
`/fouras` pour demander une énigme
`/indice` pour demander un indice sur l'énigme courante
## Setup env var for the bot
`/requete` pour demander une énigme précise
`/repete` pour que le père fouras répète l'énigme (pratique si vous avez la flemme de scroller quand il y a eu plein de messages)
- Create a `.env` file at the root of the project
- Add the following content to the file:
- `DISCORD_TOKEN` : the token of the bot
- `GITEA_API_KEY` : the api key to interact with gitea
- `GUILD_ID` : the id of the guild where the bot offer slash commands
# Pour les admins
## Comment ajouter le bot à un serveur
Un administrateur du serveur doit cliquer sur ce lien :
https://discord.com/api/oauth2/authorize?client_id=1110208055171367014&permissions=274877975552&scope=bot
## Comment configurer ou désactiver les "poil au"
`/config [ratio]` ratio est multiplié au cooldown aléatoire des "poil au", un ratio négatif désactive complètement la feature
# Pour les développeurs
## Setup python
```bash
python -m venv "venv"
source venv/bin/activate
pip install -r requirements.txt
python main.py
python -m main
```
## Setup discord
Créer un bot et générer un TOKEN sur le portail développeurs :
https://discord.com/developers/applications
## Setup l'environnement
Créer un fichier `.env` à la source du projet avec les entrées suivantes :
- `DISCORD_TOKEN` : the token of the bot
- `MAINTAINER_ID` : optional discord user id, if you want to receive the bug reports associated to your instance of the bot
## EXPERIMENTAL GITEA API
Permission needed:
- repo: read
- issues: read & write

View File

@ -258,7 +258,7 @@ marguerite
orange
amande
couleurs
montagnes
montages
ficelle
raie
cape

19
client.py Normal file
View File

@ -0,0 +1,19 @@
import discord
from discord import app_commands
from modules import FourasModule, RhymesModule
from dotenv import load_dotenv
load_dotenv()
MODULES = FourasModule, RhymesModule
intents = discord.Intents.default()
intents.members = True
intents.presences = True
intents.guilds = True
intents.messages = True
intents.message_content = True
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)

View File

@ -1,185 +0,0 @@
# database.py
import sqlite3
import random
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Tuple
DB_FILE = "data/database.db"
CREATE_GUILD_STATE_QUERY = """
INSERT INTO guild_state (guild_id, cooldown_until, cooldown_ratio, self_control, last_updated)
VALUES (?, datetime('now'), 1.0, 1.0, datetime('now'))
"""
GET_GUILD_STATE_QUERY = """
SELECT cooldown_until, cooldown_ratio, self_control, last_updated
FROM guild_state
WHERE guild_id = ?
"""
CONFIGURE_COOLDOWN_RATIO_QUERY = """
UPDATE guild_state
SET cooldown_ratio = ?
WHERE guild_id = ?
"""
DAMAGE_SELF_CONTROL_QUERY = """
UPDATE guild_state
SET self_control = self_control * 0.9
WHERE guild_id = ?
"""
RESET_COOLDOWN_QUERY = """
UPDATE guild_state
SET cooldown_until = ?,
self_control = self_control + 1.0,
last_updated = datetime('now')
WHERE guild_id = ?
"""
SAVE_RIDDLE_QUERY = """
INSERT OR REPLACE INTO active_riddles
(channel_id, riddle_index, nb_clues, message_id, solver_id, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
"""
INCREMENT_CLUE_QUERY = """
UPDATE active_riddles
SET nb_clues = nb_clues + 1
WHERE channel_id = ?
"""
DELETE_RIDDLE_QUERY = "DELETE FROM active_riddles WHERE channel_id = ?"
def ensure_db() -> None:
"""Initialize SQLite database and create tables if needed."""
Path(DB_FILE).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(DB_FILE) as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS guild_state (
guild_id TEXT PRIMARY KEY,
cooldown_until TEXT NOT NULL DEFAULT '1970-01-01T00:00:00',
cooldown_ratio REAL NOT NULL DEFAULT 1.0,
self_control REAL NOT NULL DEFAULT 1.0,
last_updated TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS active_riddles (
channel_id TEXT PRIMARY KEY,
riddle_index INTEGER NOT NULL,
nb_clues INTEGER NOT NULL DEFAULT -1,
message_id INTEGER NOT NULL,
solver_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.commit()
def get_connection() -> sqlite3.Connection:
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
return conn
def get_guild_state(guild_id: str) -> Dict[str, Any]:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(GET_GUILD_STATE_QUERY,(guild_id,))
row = cursor.fetchone()
if not row:
cursor.execute(CREATE_GUILD_STATE_QUERY, (guild_id,))
conn.commit()
cursor.execute(GET_GUILD_STATE_QUERY,(guild_id,))
row = cursor.fetchone()
if row:
return {
"cooldown_until": row["cooldown_until"],
"cooldown_ratio": row["cooldown_ratio"],
"self_control": row["self_control"],
"last_updated": row["last_updated"],
}
return {}
def rhyme_configure_cooldown_ratio(guild_id: str, cooldown_ratio: float) -> bool:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(CONFIGURE_COOLDOWN_RATIO_QUERY, (cooldown_ratio, guild_id,))
conn.commit()
def rhyme_damage_self_control(guild_id: str) -> None:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(DAMAGE_SELF_CONTROL_QUERY, (guild_id,))
conn.commit()
def rhyme_reset_cooldown(
guild_id: str,
ratio: float
) -> None:
with get_connection() as conn:
# Calculate new cooldown duration
wait_time = random.randint(900, 10800) if bool(random.getrandbits(1)) else random.randint(0, 900)
wait_time = 0 if ratio < 0 else int(wait_time * ratio)
cooldown_until = datetime.now().replace(second=0, microsecond=0) + timedelta(seconds=wait_time)
cursor = conn.cursor()
cursor.execute(RESET_COOLDOWN_QUERY, (cooldown_until.isoformat(), guild_id,))
conn.commit()
def get_active_riddle(channel_id: str) -> Any:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM active_riddles WHERE channel_id = ?", (str(channel_id),))
row = cursor.fetchone()
if row:
return {
"channel_id": row["channel_id"],
"riddle_index": row["riddle_index"],
"nb_clues": row["nb_clues"],
"message_id": row["message_id"],
"solver_id": row["solver_id"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
return None
def save_active_riddle(
channel_id: str,
riddle_index: int,
nb_clues: int,
message_id: int,
solver_id: str = "",
) -> None:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(SAVE_RIDDLE_QUERY, (channel_id, riddle_index, nb_clues, message_id, solver_id))
conn.commit()
def increment_clue(channel_id: str) -> None:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(INCREMENT_CLUE_QUERY, (channel_id,),)
conn.commit()
def remove_active_riddle(channel_id: str) -> None:
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(DELETE_RIDDLE_QUERY, (str(channel_id),))
conn.commit()

View File

@ -1,14 +0,0 @@
services:
perefouras:
build:
context: .
dockerfile: Dockerfile
environment:
- DISCORD_TOKEN=${FOURAS_DISCORD_TOKEN}
- MAINTAINER_ID=${FOURAS_MAINTAINER_ID:151626081458192384}
restart: always
volumes:
- perefouras-data:/app/data
volumes:
perefouras-data

View File

@ -10,9 +10,6 @@ RUN apt-get update -q && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Create data directory with proper permissions
RUN mkdir -p /app/data && chmod 755 /app/data
# Clone the repository
RUN git clone --single-branch --branch master https://git.epicsparrow.com/Anselme/perefouras.git .
@ -22,8 +19,5 @@ RUN pip install --no-cache-dir -r requirements.txt
# give exec permissions to main
RUN chmod +x main.py
# Set data directory as volume mount point
VOLUME /app/data
# Command to update the repo and run the script
CMD ["sh", "-c", "cd /app && git pull origin master && python main.py"]

398
fouras.py
View File

@ -1,398 +0,0 @@
# Remplacez les imports globaux et initialisations
import json
import os
import random
import re
from typing import Any, Dict
import discord
from discord import Interaction, app_commands
from unidecode import unidecode
import database
riddles = []
answers = []
API_URL = "".join(
[
"https://discord.com/api/oauth2/authorize?",
"client_id=1110208055171367014&permissions=274877975552&scope=bot",
]
)
AUTHOR_ID = 151626081458192384
MAINTAINER_ID = os.getenv("MAINTAINER_ID", AUTHOR_ID)
ABOUT = """
Ce bot a été développé par {user}
Code Source : https://git.epicsparrow.com/Anselme/perefouras
Ajouter ce bot à votre serveur : {url}
"""
BUG_REPORT = """
BUG REPORT from {user} (`{user_id}`) in channel {channel} (`{channel_id}`) :
> {message}
History :
```json\n{history}```
"""
SUCCESS = """
Bravo {user} ! La réponse était bien `{answer}`.
"""
INVALID_ID = """
Numéro d'énigme invalide, merci de saisir un numéro entre 1 et {len}
"""
RIDDLES_FILE = "resources/riddles.txt"
ANSWERS_FILE = "resources/answers.txt"
def load_riddles() -> list:
global riddles, answers
riddles = []
with open(RIDDLES_FILE, "r", encoding="utf-8") as f:
riddles = f.read().split("\n\n")
answers = []
with open(ANSWERS_FILE, "r", encoding="utf-8") as f:
answers = [line.strip() for line in f.readlines()]
print(f"Loaded {len(riddles)} riddles")
def new_riddle_state(index: int) -> Dict[str, Any]:
return {
"index": index,
"nbClues": -1,
"riddle": riddles[index].strip(),
"answer": answers[index],
}
async def finish_riddle(channel, client: discord.Client) -> None:
database.remove_active_riddle(str(channel.id))
def clue_string(answer: str, nb_clues: int) -> str:
final_string = "_"
for _ in range(len(answer) - 1):
final_string += " _"
random.seed(hash(answer))
nb_revealed = 0
for _ in range(nb_clues):
idx = random.randint(0, len(answer) - 1)
while final_string[idx * 2] != "_":
idx = random.randint(0, len(answer) - 1)
nb_revealed += 1
final_string = (
final_string[: idx * 2] + answer[idx] + final_string[idx * 2 + 1 :]
)
if nb_revealed == len(answer):
return final_string
return final_string
def format_riddle_message(
current_riddle: Dict[str, Any], solved: bool = False, solver_mention: str = ""
) -> str:
nb_clues = current_riddle.get("nbClues", -1)
answer = current_riddle.get("answer", "")
solver = None
if solved and solver_mention:
solver = type("obj", (object,), {"mention": solver_mention})()
formatted_riddle = "> " + current_riddle["riddle"].replace("\n", "\n> ")
formatted_riddle = formatted_riddle.replace("\r", "")
clue = ""
if nb_clues > -1:
if nb_clues >= len(answer):
clue = "\nNon trouvée, la solution était : `{0}`".format(answer)
else:
clue = "\nIndice : `{0}`".format(clue_string(answer, nb_clues))
if solved and solver:
clue = clue + "\n{0} a trouvé la solution, qui était : `{1}`".format(
solver.mention, answer
)
if clue:
return "Énigme {0}:\n{1}\n> Qui suis-je ?\n{2}".format(
current_riddle["index"] + 1, formatted_riddle, clue
)
else:
return "Énigme {0}:\n{1}\n> Qui suis-je ?".format(
current_riddle["index"] + 1, formatted_riddle
)
async def get_channel_name(channel, client: discord.Client) -> str:
if isinstance(channel, discord.DMChannel):
dm_channel = await client.fetch_channel(channel.id)
return "[DM={0}]".format(dm_channel.recipient.name)
else:
return "[Server={0}] => [Channel={1}]".format(channel.guild.name, channel.name)
async def handle_debug_commands(message, client: discord.Client) -> bool:
broadcast_match = re.match(r"^broadcast\s+(\d+) (.*)", message.content)
if broadcast_match and message.author.id == MAINTAINER_ID:
index = int(broadcast_match.group(1))
broadcast_message = broadcast_match.group(2)
try:
channel = await client.fetch_channel(index)
if channel:
await channel.send(broadcast_message)
else:
await message.channel.send(f"Invalid channel id : {index}")
except discord.errors.NotFound:
await message.channel.send(f"Channel not found : {index}")
return True
return False
def get_current_riddle(channel_id):
riddle_data = database.get_active_riddle(str(channel_id))
if not riddle_data:
return None
if 0 <= riddle_data["riddle_index"] < len(riddles):
return {
"index": riddle_data["riddle_index"],
"nbClues": riddle_data["nb_clues"],
"riddle": riddles[riddle_data["riddle_index"]].strip(),
"answer": answers[riddle_data["riddle_index"]],
"data": riddle_data
}
else:
return None
async def handle_riddle_solving(message, client: discord.Client) -> bool:
current_riddle = get_current_riddle(message.channel.id)
if(not current_riddle):
return False
answer = current_riddle["answer"]
riddle_data = current_riddle["data"]
if unidecode(answer.lower()) in unidecode(message.content.lower()):
solver_id = str(message.author.id)
database.save_active_riddle(
channel_id=str(message.channel.id),
riddle_index=riddle_data["riddle_index"],
nb_clues=current_riddle["nbClues"],
message_id=riddle_data["message_id"],
solver_id=solver_id,
)
await message.channel.send(
SUCCESS.format(user=message.author.mention, answer=answer)
)
try:
original_msg = await message.channel.fetch_message(
riddle_data["message_id"]
)
await original_msg.edit(
content=format_riddle_message(
current_riddle, solved=True, solver_mention=message.author.mention
)
)
except discord.errors.NotFound:
pass
await finish_riddle(message.channel, client)
return True
return False
async def handle_bug_report(message, client: discord.Client) -> bool:
if not message.content.lower().startswith("bug"):
return False
author_user = await client.fetch_user(MAINTAINER_ID)
channel_name = await get_channel_name(message.channel, client)
messages = [
{
"id": msg.id,
"content": msg.content,
"date": msg.created_at.strftime("%d/%m %H:%M:%S"),
}
async for msg in message.channel.history(limit=6)
]
messages_json = json.dumps(messages, ensure_ascii=False)
active_riddle = database.get_active_riddle(message.channel.id)
state_json = json.dumps(active_riddle, ensure_ascii=False, indent=2)
await author_user.send(
BUG_REPORT.format(
user=message.author.mention,
user_id=message.author.id,
channel=channel_name,
channel_id=message.channel.id,
message=message.content,
history=messages_json,
state=state_json,
)
)
await message.channel.send(
f"Rapport de bug envoyé à {author_user.mention}\nMerci de ton feedback !"
)
return True
async def handle_message(message, client: discord.Client) -> bool:
database.ensure_db()
if await handle_debug_commands(message, client):
return True
if await handle_bug_report(message, client):
return True
if await handle_riddle_solving(message, client):
return True
return False
async def new_riddle(interaction: Interaction, index: int) -> None:
if random.random() <= 0.03:
await interaction.response.send_message("Non")
else:
riddle_state = new_riddle_state(index)
msg = await interaction.response.send_message(
format_riddle_message({**riddle_state, "index": index})
)
database.save_active_riddle(
channel_id=str(interaction.channel.id),
riddle_index=index,
nb_clues=-1,
message_id=msg.message_id,
solver_id=None
)
async def update_riddle_message(channel, current_riddle):
try:
original_msg = await channel.fetch_message(current_riddle["data"]["message_id"])
await original_msg.edit(content=format_riddle_message(current_riddle))
return True
except discord.errors.NotFound:
return False
def setup_commands(client: discord.Client, tree) -> None:
@tree.command(
name="fouras",
description="Demander une énigme au Père Fouras"
)
async def fouras(
interaction: discord.Interaction,
):
if len(riddles) == 0:
await interaction.response.send_message("Désolé, mais j'ai oublié mes énigmes", ephemeral=True)
else:
index = random.randint(0, len(riddles) - 1)
await new_riddle(interaction, index)
@tree.command(
name="requete",
description="Demander une énigme spécifique au Père Fouras"
)
@app_commands.describe(index="Numéro de l'énigme")
async def requete(
interaction: discord.Interaction,
index: int,
):
index = index - 1
if not (0 <= index < len(riddles)):
await interaction.response.send_message(INVALID_ID.format(len=len(riddles)+1), ephemeral=True)
return
await new_riddle(interaction, index)
@tree.command(
name="repete",
description="Demander au Père Fouras de répéter l'énigme en cours"
)
async def repete(
interaction: discord.Interaction,
):
current_riddle = get_current_riddle(interaction.channel.id)
if(not current_riddle):
await interaction.response.send_message("Aucune énigme en cours", ephemeral=True)
return
riddle_data = current_riddle["data"]
msg = await interaction.response.send_message(
format_riddle_message(current_riddle)
)
database.save_active_riddle(
channel_id=str(interaction.channel.id),
riddle_index=riddle_data["riddle_index"],
nb_clues=current_riddle["nbClues"],
message_id=msg.message_id,
solver_id=riddle_data["solver_id"],
)
# @tree.command(
# name="about",
# description="Qui est le Père Fouras ?"
# )
# async def about(
# interaction: discord.Interaction,
# ):
# author_user = await client.fetch_user(AUTHOR_ID)
# await interaction.response.send_message(ABOUT.format(user=author_user.mention, url=API_URL), ephemeral=True)
@tree.command(
name="indice",
description="Demander un indice pour l'énigme en cours"
)
async def indice(
interaction: discord.Interaction,
):
current_riddle = get_current_riddle(interaction.channel.id)
if(not current_riddle):
await interaction.response.send_message("Aucune énigme en cours", ephemeral=True)
return
answer = current_riddle["answer"]
riddle_data = current_riddle["data"]
nb_clues = current_riddle["nbClues"] + 1
current_riddle["nbClues"] = nb_clues
if nb_clues >= len(answer):
await interaction.response.send_message(
"Perdu ! La réponse était : `{0}`".format(answer)
)
if(not await update_riddle_message(interaction.channel, current_riddle)):
return
await finish_riddle(interaction.channel, client)
else:
database.save_active_riddle(
channel_id=str(interaction.channel.id),
riddle_index=riddle_data["riddle_index"],
nb_clues=nb_clues,
message_id=riddle_data["message_id"],
solver_id=riddle_data["solver_id"],
)
if(not await update_riddle_message(interaction.channel, current_riddle)):
return
await interaction.response.send_message("Nouvel indice : `{0}`".format(clue_string(answer, nb_clues)))

73
main.py
View File

@ -1,63 +1,50 @@
import os
import discord
from discord import app_commands
from dotenv import load_dotenv
import os
from modules.base import BaseModule
load_dotenv()
from client import client, MODULES, tree
from modules import gitea
intents = discord.Intents.default()
intents.members = True
intents.presences = True
intents.guilds = True
intents.messages = True
intents.message_content = True
token = os.getenv("DISCORD_TOKEN", "NO_TOKEN")
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)
client.riddles = []
client.answers = []
client.rhyme_keys = {}
client.rhyme_strings = {}
client.cooldown = {}
client.ongoing_riddles = {}
client.modules: list[BaseModule] = []
@client.event
async def on_ready():
global client, tree
from fouras import load_riddles, setup_commands
from rhymes import ensure_log_file, load_rhymes, setup_rhymes_commands
ensure_log_file()
load_riddles()
setup_commands(client, tree)
setup_rhymes_commands(client, tree)
success, msg = load_rhymes()
print(msg)
client.modules = [m(client) for m in MODULES]
for m in client.modules:
await m.load()
async for guild in client.fetch_guilds():
tree.copy_global_to(guild=guild)
await tree.sync(guild=guild)
print(f"Logged in as {client.user} on {len(client.guilds)} servers!")
@client.event
async def on_message(message):
global client
if message.author == client.user:
# don't answer to self
if (
message.author == client.user and message.channel in client.ongoing_riddles
): # need to move a part of that block in FourasModule
current_riddle = client.ongoing_riddles[message.channel]
if "message" not in current_riddle:
current_riddle["message"] = message
return
from fouras import handle_message as handle_fouras
if isinstance(
message.channel, (discord.DMChannel, discord.TextChannel, discord.Thread)
):
handled = False
for m in client.modules:
if not handled:
handled = await m.handle_message(message)
await handle_fouras(message, client)
from rhymes import handle_message as handle_rhymes
await handle_rhymes(message, client)
if __name__ == "__main__":
token = os.getenv("DISCORD_TOKEN")
if not token:
print("Error: DISCORD_TOKEN not found in environment variables")
exit(1)
# Initialise le client
client.run(token)

4
modules/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from .fouras import FourasModule
from .rhymes import RhymesModule
ALL = [FourasModule, RhymesModule]

44
modules/base.py Normal file
View File

@ -0,0 +1,44 @@
import discord
import json
ENCODING = "utf-8"
class BaseModule:
_client = None
def __init__(self, client):
self._client = client
async def load(self):
raise NotImplementedError
async def save(self, save_to_file=True):
raise NotImplementedError
async def handle_message(self, message) -> bool:
raise NotImplementedError
async def load_history(self, channel):
messages = [
{
"id": message.id,
"content": message.content,
"date": message.created_at.strftime("%d/%m %H:%M:%S"),
}
async for message in channel.history(limit=10)
]
return json.dumps(messages, ensure_ascii=False)
async def get_guild_name(self, guildId) -> str:
guild = await self._client.fetch_guild(guildId)
return "[Server={0}]".format(guild.name)
async def get_channel_name(self, channel) -> str:
if isinstance(channel, discord.DMChannel):
dm_channel = await self._client.fetch_channel(channel.id)
return "[DM={0}]".format(dm_channel.recipient.name)
else:
return "[Server={0}] => [Channel={1}]".format(
channel.guild.name, channel.name
)

295
modules/fouras.py Normal file
View File

@ -0,0 +1,295 @@
from .base import BaseModule, ENCODING
import random
import re
import json
from unidecode import unidecode
import appdirs
import os
API_URL = "".join(
[
"https://discord.com/api/oauth2/authorize?",
"client_id=1110208055171367014&permissions=274877975552&scope=bot",
]
)
MAINTAINER_ID = 151626081458192384
BUG_REPORT = """
BUG REPORT from {user} (`{user_id}`) in channel {channel} (`{channel_id}`) :
Message :
> {message}
State :
```json\n{state}```
History :
```json\n{history}```
"""
ABOUT = """
Ce bot a été développé par {user}
Code Source : https://git.epicsparrow.com/Anselme/perefouras
Ajouter ce bot à votre serveur : {url}
"""
SUCCESS = """
Bravo {user} ! La réponse était bien `{answer}`.
"""
INVALID_ID = """
Numéro d'énigme invalide, merci de saisir un numéro entre 1 et {len}
"""
RIDDLES_FILE = "riddles.txt"
ANSWERS_FILE = "answers.txt"
SAVE_FILE = appdirs.user_data_dir() + "/PereFouras/fouras_riddles.json"
class FourasModule(BaseModule):
async def load(self):
with open(RIDDLES_FILE, "r", encoding=ENCODING) as r_file:
self._client.riddles = r_file.read().split("\n\n")
with open(ANSWERS_FILE, "r", encoding=ENCODING) as a_file:
self._client.answers = [line.strip() for line in a_file.readlines()]
str = f"Loaded {len(self._client.riddles)} riddles"
try:
with open(SAVE_FILE, "r") as file:
config = json.load(file)
ongoing_riddles = dict()
for k, v in config.items():
channel = await self._client.fetch_channel(int(k))
channel_info = v
channel_info["message"] = await channel.fetch_message(
channel_info["message"]
)
ongoing_riddles[channel] = channel_info
self._client.ongoing_riddles = ongoing_riddles
str = str + 'Loaded fouras save file "{0}"'.format(SAVE_FILE)
except FileNotFoundError:
str = str + 'No previous "{0}" save file found'.format(SAVE_FILE)
except json.JSONDecodeError:
str = str + '"{0}" is an invalid JSON file.'.format(SAVE_FILE)
print(str)
return str
async def save(self, save_to_file=True):
dump = {}
for key, value in self._client.ongoing_riddles.items():
dump_channel = dict(value)
dump_channel["message"] = dump_channel["message"].id
dump[key.id] = dump_channel
os.makedirs(os.path.dirname(SAVE_FILE), exist_ok=True)
with open(SAVE_FILE, "w") as file:
json.dump(dump, file, ensure_ascii=False)
print('Saved fouras riddles state in file "{0}"'.format(SAVE_FILE))
return dump
def new_riddle(self, channel, index):
current_riddle = dict(
index=index,
nbClues=-1,
riddle=self._client.riddles[index].strip(),
answer=self._client.answers[index],
)
self._client.ongoing_riddles[channel] = current_riddle
return self.format_message(current_riddle)
def finish_riddle(self, channel):
del self._client.ongoing_riddles[channel]
def clue_string(self, answer, nbClues):
finalString = "_"
for i in range(len(answer) - 1):
finalString += " _"
random.seed(hash(answer))
nbRevealed = 0
for i in range(nbClues):
id = random.randint(0, len(answer) - 1)
while finalString[id * 2] != "_":
id = random.randint(0, len(answer) - 1)
nbRevealed += 1
finalString = finalString[: id * 2] + answer[id] + finalString[id * 2 + 1 :]
if nbRevealed == len(answer):
return finalString
return finalString
def format_message(self, current_riddle):
nbClues = current_riddle["nbClues"]
answer = current_riddle["answer"]
formatted_riddle = "> " + current_riddle["riddle"].replace("\n", "\n> ")
formatted_riddle = formatted_riddle.replace("\r", "")
clue = ""
if nbClues > -1:
if nbClues >= len(answer):
clue = clue + "\nNon trouvée, la solution était : `{0}`".format(answer)
else:
clue = clue + "\nIndice : `{0}`".format(
self.clue_string(answer, nbClues)
)
if "solver" in current_riddle:
clue = clue + "\n{0} a trouvé la solution, qui était : `{1}`".format(
current_riddle["solver"].mention, answer
)
if clue:
return "Énigme {0}:\n{1}\n> Qui suis-je ?\n{2}".format(
current_riddle["index"] + 1, formatted_riddle, clue
)
else:
return "Énigme {0}:\n{1}\n> Qui suis-je ?".format(
current_riddle["index"] + 1, formatted_riddle
)
async def handle_message(self, message) -> bool:
if message.author == self._client.user:
return False
message_content = message.content.lower()
# command fouras
fouras_match = re.match(r"^fouras\s+(\d+)$", message_content)
if fouras_match:
index = int(fouras_match.group(1)) - 1
if index >= 0 and index < len(self._client.riddles):
if random.random() <= 0.03:
await message.channel.send("Non")
else:
await message.channel.send(self.new_riddle(message.channel, index))
else:
await message.channel.send(
INVALID_ID.format(len=len(self._client.riddles))
)
return True
if message_content == "fouras":
if random.random() <= 0.03:
await message.channel.send("Non")
elif len(self._client.riddles) > 0:
index = random.randint(0, len(self._client.riddles) - 1)
await message.channel.send(self.new_riddle(message.channel, index))
return True
if message_content.startswith("bug"):
author_user = await self._client.fetch_user(MAINTAINER_ID)
channel_name = await self.get_channel_name(message.channel)
messages_json = await self.load_history(message.channel)
await author_user.send(
BUG_REPORT.format(
user=message.author.mention,
user_id=message.author.id,
channel=channel_name,
channel_id=message.channel.id,
message=message_content,
history=messages_json,
state=self.save(False),
)
)
await message.channel.send(
f"Rapport de bug envoyé à {author_user.mention}\nMerci de ton feedback !"
)
return True
broadcast_match = re.match(r"^broadcast\s+(\d+) (.*)", message.content)
if broadcast_match and message.author.id == MAINTAINER_ID:
index = int(broadcast_match.group(1))
broadcast_message = broadcast_match.group(2)
channel = await self._client.fetch_channel(index)
if channel:
await channel.send(broadcast_message)
else:
await message.channel.send(f"Invalid channel id : {index}")
return True
if message_content == "about fouras":
author_user = await self._client.fetch_user(MAINTAINER_ID)
await message.channel.send(
ABOUT.format(user=author_user.mention, url=API_URL)
)
return True
if message_content == "save fouras":
if message.author.id == 151626081458192384:
json_str = "```json\n{0}```".format(
json.dumps(self.save(), ensure_ascii=False, indent=2)
)
await message.author.send(json_str)
return True
if message_content == "load fouras":
if message.author.id == 151626081458192384:
await self.load()
await message.author.send(
"Loaded {0} riddles".format(len(self._client.riddles))
)
return True
if message_content == "debug fouras":
if message.author.id == 151626081458192384:
dump = {}
for key, value in self._client.ongoing_riddles.items():
dump_channel = dict(value)
dump_channel.pop("message", None)
dump_channel.pop("answer", None)
channel_name = await self.get_channel_name(key)
dump[channel_name] = dump_channel
await message.author.send(
"```json\n{0}```".format(
json.dumps(dump, ensure_ascii=False, indent=4)
)
)
return True
# if current channel has ongoing riddle
if message.channel in self._client.ongoing_riddles:
current_riddle = self._client.ongoing_riddles[message.channel]
if "message" in current_riddle:
answer = current_riddle["answer"]
if unidecode(answer.lower()) in unidecode(message_content):
current_riddle["solver"] = message.author
await message.channel.send(
SUCCESS.format(user=message.author.mention, answer=answer)
)
await current_riddle["message"].edit(
content=self.format_message(current_riddle)
)
self.finish_riddle(message.channel)
return True
if (
message_content == "repete"
or message_content == "répète"
or message_content == "repeat"
):
current_riddle.pop("message")
await message.channel.send(self.format_message(current_riddle))
return True
# Commande /clue : révèle une lettre au hasard de la réponse attendue
if (
message_content == "indice"
or message_content == "aide"
or message_content == "help"
or message_content == "clue"
):
nbClues = current_riddle["nbClues"] + 1
current_riddle["nbClues"] = nbClues
if nbClues >= len(answer):
reply = "Perdu ! La réponse était : `{0}`".format(answer)
await message.channel.send(reply)
# else:
# reply = "Indice : `{0}`".format(clue_string(answer, nbClues))
await current_riddle["message"].edit(
content=self.format_message(current_riddle)
)
if nbClues >= len(answer):
self.finish_riddle(message.channel)
return True
return False

69
modules/gitea.py Normal file
View File

@ -0,0 +1,69 @@
import os
import discord
from discord import app_commands
import httpx
from client import client, tree
GUILD_ID = os.getenv("GUILD_ID")
GITEA_API_KEY = os.getenv("GITEA_API_KEY")
gitea_url = "https://git.epicsparrow.com/api/v1"
GITEA_PROJECTS = {}
auth_headers = {"Authorization": f"token {GITEA_API_KEY}"}
def init_gitea_projects():
res = httpx.get(gitea_url + "/repos/search", headers=auth_headers)
if res.status_code == 200:
GITEA_PROJECTS.update(
{
str(project["id"]): {
"name": project["name"],
"owner": project["owner"]["login"],
}
for project in res.json()["data"]
}
)
return [(project["name"], project["id"]) for project in res.json()["data"]]
else:
return []
init_gitea_projects()
@tree.command(
name="gitea-issue",
description="Create issues to gitea",
)
@app_commands.describe(
title="Issue title", project="The project where the issue is created"
)
@app_commands.choices(
project=[
app_commands.Choice(name=project["name"], value=id_)
for id_, project in GITEA_PROJECTS.items()
]
)
async def gitea(interaction: discord.Interaction, project: str, title: str):
embed = discord.Embed(title="Gitea issue")
embed.add_field(name="Project", value=GITEA_PROJECTS[project]["name"])
embed.add_field(name="Title", value=title)
embed.add_field(name="Created by", value=interaction.user.mention)
creation_url = f"{gitea_url}/repos/{GITEA_PROJECTS[project]['owner']}/{GITEA_PROJECTS[project]['name']}/issues"
creation_data = {
"title": title,
"body": f"Created by {interaction.user.nick or interaction.user.name} from Discord.",
}
res = httpx.post(creation_url, headers=auth_headers, data=creation_data)
if res.status_code == 201:
embed.add_field(name="Issue created", value=res.json()["html_url"])
else:
embed.add_field(name="Error", value=res.text)
await interaction.response.send_message(embed=embed)

136
modules/rhymes.py Normal file
View File

@ -0,0 +1,136 @@
from .base import BaseModule
import random
import time
import json
import appdirs
import os
RHYMES_FILE = "rhymes.json"
SAVE_FILE = appdirs.user_data_dir() + "/PereFouras/poilau_save.json"
# CONFIG_TEXT = """
# Ce bot a été développé par {user}
# Code Source : https://git.epicsparrow.com/Anselme/perefouras
# Ajouter ce bot à votre serveur : {url}
# """
class RhymesModule(BaseModule):
rhymes: list = []
guild_config: dict = {}
async def load(self):
str = ""
with open(RHYMES_FILE, "r") as f:
self.rhymes = json.load(f)
try:
with open(SAVE_FILE, "r") as file:
self.guild_config = json.load(file)
str = 'Loaded poilau save file "{0}"'.format(SAVE_FILE)
except FileNotFoundError:
str = 'No previous "{0}" save file found'.format(SAVE_FILE)
except json.JSONDecodeError:
str = '"{0}" is an invalid JSON file.'.format(SAVE_FILE)
print(str)
return str
async def save(self, save_to_file=True):
os.makedirs(os.path.dirname(SAVE_FILE), exist_ok=True)
with open(SAVE_FILE, "w") as file:
json.dump(self.guild_config, file, ensure_ascii=False, indent=2)
print('Saved poilau state in file "{0}"'.format(SAVE_FILE))
def get_last_word(self, ch: str) -> str:
truncated = ch
while True:
if len(truncated) < 2 or truncated[-1].isnumeric():
return ""
if truncated[-1].isalpha() and truncated[-2].isalpha():
break
truncated = truncated[:-1]
truncated = truncated.split(" ")[-1]
if truncated.isalpha():
return truncated
else:
return ""
def poil_auquel(self, ch: str) -> str:
for rhyme in self.rhymes:
if ch in rhyme["blacklist"]:
return ""
if ch.endswith(tuple(rhyme["keys"])):
return random.choice(rhyme["rhymes"])
return ""
async def handle_message(self, message) -> bool:
message_content = message.content.lower()
if message_content == "debug poilau":
if message.author.id == 151626081458192384:
dump = {}
for key, value in self.guild_config.items():
channel_name = await self.get_guild_name(key)
sleeping_time = "{:.2f} s".format(
max(0, value["cooldown"] - time.time())
)
dump[channel_name] = {
"cooldown": sleeping_time,
"self-control": value["self-control"],
}
await message.author.send(
"```json\n{0}```".format(
json.dumps(dump, ensure_ascii=False, indent=2)
)
)
return True
if message_content == "save poilau":
if message.author.id == 151626081458192384:
self.save()
json_str = "```json\n{0}```".format(
json.dumps(self.guild_config, ensure_ascii=False, indent=2)
)
await message.author.send(json_str)
return True
if message_content == "load poilau":
if message.author.id == 151626081458192384:
await message.author.send(await self.load())
json_str = "```json\n{0}```".format(
json.dumps(self.guild_config, ensure_ascii=False, indent=2)
)
await message.author.send(json_str)
return True
if message_content == "tg fouras" and message.guild:
self.guild_config[str(message.guild.id)] = {
"cooldown": time.time() + 40000,
"self-control": 2.0,
}
await message.channel.send("ok :'(")
return True
last_word = self.get_last_word(message_content)
if message.author != self._client.user and message.guild and last_word:
poil = self.poil_auquel(last_word)
guildId = str(message.guild.id)
guild_config = self.guild_config.get(
guildId, {"cooldown": 0, "self-control": 1.0}
)
if poil and time.time() - guild_config["cooldown"] > 0:
self_control = guild_config["self-control"]
if random.random() < self_control:
guild_config["self-control"] = self_control * 0.9
self.guild_config[guildId] = guild_config
return False
wait_time = random.randint(0, 900)
if bool(random.getrandbits(1)):
wait_time = random.randint(900, 10800)
self.guild_config[guildId] = {
"cooldown": time.time() + wait_time,
"self-control": self_control + 1.0,
}
await message.channel.send(poil)
return True
return False

View File

@ -1,7 +1,7 @@
[
{
"sound": "E",
"blacklist": ["cheveux", "cheveu", "queue", "queues", "eu"],
"blacklist": ["cheveux", "cheveu", "queue", "queues"],
"keys": [
"eu",
"eus",
@ -27,7 +27,7 @@
},
{
"sound": "ETTE",
"blacklist": ["quéquette", "quéquettes", "zigounette", "zigounettes", "zézette", "zézettes"],
"blacklist": ["quéquette", "quéquettes"],
"keys": [
"ette",
"ett",
@ -42,9 +42,7 @@
"aites"
],
"rhymes": [
"Poil à la quéquette.",
"Poil à la zigounette.",
"Poil à la zézette."
"Poil à la quéquette."
]
},
{
@ -75,7 +73,7 @@
},
{
"sound": "OU",
"blacklist": ["cou", "genou", "genous", "minou", "minous"],
"blacklist": ["cou", "genou", "genous"],
"keys": [
"ou",
"où",
@ -83,13 +81,12 @@
],
"rhymes": [
"Poil au cou.",
"Poil au genou.",
"Poil au minou."
"Poil au genou."
]
},
{
"sound": "O",
"blacklist": ["dos", "bot"],
"blacklist": ["dos"],
"keys": [
"au",
"aux",
@ -104,17 +101,6 @@
"Poil au dos."
]
},
{
"sound": "OTTE",
"blacklist": ["glotte"],
"keys": [
"otte",
"ott"
],
"rhymes": [
"Poil à la glotte."
]
},
{
"sound": "OUL",
"blacklist": ["moule", "moules", "boule", "boules", "alcool"],
@ -200,7 +186,7 @@
},
{
"sound": "AI",
"blacklist": ["raie", "raies", "wait"],
"blacklist": ["raie", "raies"],
"keys": [
"ai",
"ait",
@ -350,7 +336,7 @@
},
{
"sound": "IS",
"blacklist": ["pénis", "penis", "jolis"],
"blacklist": ["pénis", "penis"],
"keys": [
"is",
"isse",
@ -360,20 +346,6 @@
"Poil au pénis."
]
},
{
"sound": "I",
"blacklist": ["zizi", "zizis"],
"keys": [
"i",
"ea",
"ee",
"it",
"ie"
],
"rhymes": [
"Poil au zizi."
]
},
{
"sound": "A",
"blacklist": ["bras"],
@ -387,6 +359,19 @@
"Poil au bras."
]
},
{
"sound": "I",
"blacklist": ["zizi", "zizis"],
"keys": [
"i",
"ee",
"it",
"ie"
],
"rhymes": [
"Poil au zizi."
]
},
{
"sound": "U",
"blacklist": ["cul", "culs"],

132
rhymes.py
View File

@ -1,132 +0,0 @@
# rhymes.py
import json
import random
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Tuple
import discord
from discord import Interaction, app_commands
import database
RHYMES_FILE = "resources/rhymes.json"
RHYME_LOG_FILE = "data/rhyme_log.csv"
loaded_rhymes = {}
def ensure_log_file() -> None:
Path(RHYME_LOG_FILE).parent.mkdir(parents=True, exist_ok=True)
if not Path(RHYME_LOG_FILE).exists():
with open(RHYME_LOG_FILE, "w", encoding="utf-8") as f:
f.write("timestamp,last_word,rhyme_triggered\n")
def load_rhymes() -> Tuple[bool, str]:
global loaded_rhymes
try:
with open(RHYMES_FILE, "r", encoding="utf-8") as f:
loaded_rhymes = json.load(f)
return True, f'Loaded rhymes file "{RHYMES_FILE}"'
except FileNotFoundError:
return False, f'No rhymes file found at "{RHYMES_FILE}"'
except json.JSONDecodeError as e:
return False, f'Invalid JSON in "{RHYMES_FILE}": {e}'
def log_rhyme(last_word: str, rhyme_triggered: str) -> None:
timestamp = datetime.now().isoformat()
with open(RHYME_LOG_FILE, "a", encoding="utf-8") as f:
safe_rhyme = rhyme_triggered.replace(",", ";")
f.write(f"{timestamp},{last_word},{safe_rhyme}\n")
def get_last_word(text: str) -> str:
truncated = text
while True:
if len(truncated) < 2 or truncated[-1].isnumeric():
return ""
if truncated[-1].isalpha() and truncated[-2].isalpha():
break
truncated = truncated[:-1]
truncated = truncated.split(" ")[-1]
return truncated if truncated.isalpha() else ""
def find_rhyme(word: str) -> str:
global loaded_rhymes
for rhyme in loaded_rhymes:
if word in rhyme["blacklist"]:
return ""
if word.endswith(tuple(rhyme["keys"])):
log_rhyme(word, rhyme["sound"])
return random.choice(rhyme["rhymes"])
return ""
async def get_guild_name(guildId, client) -> str:
guild = await client.fetch_guild(guildId)
return "[Server={0}]".format(guild.name)
async def handle_rhyme_logic(message, client) -> bool:
message_content = message.content.lower()
last_word = get_last_word(message_content)
if message.author != client.user and message.guild and last_word:
rhyme = find_rhyme(last_word)
guild_id = str(message.guild.id)
if rhyme:
guild_state = database.get_guild_state(guild_id)
cooldown_ratio = guild_state["cooldown_ratio"]
if cooldown_ratio >= 0:
cooldown_dt = datetime.fromisoformat(guild_state["cooldown_until"])
now_dt = datetime.now()
if now_dt >= cooldown_dt:
self_control = guild_state["self_control"]
if random.random() < self_control:
database.rhyme_damage_self_control(guild_id)
return False
database.rhyme_reset_cooldown(guild_id, cooldown_ratio)
await message.channel.send(rhyme)
return True
return False
async def handle_message(message, client) -> bool:
database.ensure_db()
ensure_log_file()
return await handle_rhyme_logic(message, client)
def setup_rhymes_commands(client: discord.Client, tree: discord.app_commands.CommandTree) -> None:
@tree.command(
name="config",
description="Permet de configurer à quel point le Père Fouras est relou"
)
@app_commands.default_permissions(manage_guild=True)
@app_commands.checks.has_permissions(manage_guild=True)
@app_commands.describe(ratio="ratio de cooldown (0 = )")
async def config(
interaction: discord.Interaction,
ratio: float
):
guild_id: str = str(interaction.guild_id)
guild_state = database.get_guild_state(guild_id)
database.rhyme_configure_cooldown_ratio(guild_id, ratio)
msg = f"Le ratio de cooldown passe de {guild_state["cooldown_ratio"]:.2f} à {ratio:.2f} pour le serveur {interaction.guild.name}"
if ratio < 0:
await interaction.response.send_message(f"{msg}\nLes \"poil au\" sont désormais désactivés.", ephemeral=True)
else:
await interaction.response.send_message(f"{msg}\nLes \"poil au\" ont désormais un cooldown entre 0 et {int(180*ratio)} minutes.", ephemeral=True)

View File

@ -932,7 +932,7 @@ Qui parlent souvent d'amour
D'une grande dureté est le bois de son cœur
Son nom témoigne de sa couleur
Taillée, sculptée elle est exotique
Taillé, sculpté elle est exotique
Elle fait l'objet d'un certain trafic
Elle plonge dans un bruit
@ -1166,23 +1166,23 @@ Secret, il l'est pour ceux qui aiment se taire.
Souvent causé par la distraction,
Cette maladresse peut avoir des répercutions.
Cette perche sert à manœuvrer un bateau,
Ou à sortir un poisson de l'eau.
Cette perche sert a manœuvrée,
Un bateau ou a sortir un poisson de l'eau.
Gage d'amitié,
Sa valeur est sans importance.
Coutume de civilité,
Il n'est que convenance.
Pour la police, ils sont une clé
Pour la police, ils sont un clé
Lorsqu'ils se font tirer le portrait.
Ils sont nombreux dans les cuisines
Et ne lésinent pas dans les usines.
Du fond de la Provence,
Sous le mistral elle danse,
De son cœur, nait l'essence
À une couleur elle a donné naissance.
De son cœur, née l'essence
A une couleur elle a donnée naissance.
On s'appuie très souvent dessus,
Il a régulièrement des frais
@ -1196,7 +1196,7 @@ Elle devient un commandement.
Il peut s'agir d'un soupçon
Car c'est une petite quantité
On aime cette peau si parfumée
On aime cette peau si parfumé
Provenant de l'orange ou du citron
Au balcon vous l'apercevez,
@ -1214,7 +1214,7 @@ Ce sobriquet est peut flatteur
Il hiberne au fond d'un terrier
Et le savon il fait mousser.
Autrefois elle acheminait bien du courrier
Autrefois elle acheminée bien du courrier
Elle est souvent synonyme de voyages
Parfois au fond d'un grenier
S'y entasse des souvenirs sans âge.
@ -1364,7 +1364,7 @@ N'est pas celle blindée
C'est une opération,
Qui aide à mieux régner
Parfois mauvaise et redoutée
Parfois mauvaise et redouté
Elle peut faire des blessés
Qu'elle soit d'eau ou de reins
Elle est aussi mot de la fin
@ -1407,7 +1407,7 @@ Les paresseux le trouvent tellement pratique.
C'est le synonyme de "gros",
Par qui on jauge les bateaux.
Mais si vous êtes dans l'auto,
Moins vous en faites et mieux ça vaut.
Moins vous en faites et mieux çà vaut.
Qu'il soit décoré ou peint,
Chaque noble a le sien.