Compare commits

...

15 Commits

Author SHA1 Message Date
Anselme
2a52d31295 Updated dockerfile 2026-08-06 16:51:04 +02:00
Anselme
51496646f4 code cleanup, removed useless comments 2026-08-06 16:20:08 +02:00
Anselme
4e966eb821 added config command to disable rhymes 2026-08-06 15:26:57 +02:00
Anselme
b38d596bca fixed grammar in riddle 2026-07-31 14:41:44 +02:00
Anselme
bc688c0f62 added a few rhymes 2026-07-31 11:47:36 +02:00
Anselme
37416eca80 simpler query response 2026-07-31 11:47:27 +02:00
Anselme
56bb781791 fixed typos in riddles 2026-07-31 11:05:50 +02:00
Anselme
3b2633f780 added cooldown ratio to rhymes 2026-07-17 19:45:11 +02:00
Anselme
45b6b52d10 updated readme 2026-07-17 11:41:06 +02:00
Anselme
6c8a729f73 fixed typos and bad rhymes 2026-07-15 14:22:27 +02:00
Anselme
b836d3fceb moved maintainer_id to .env 2026-07-11 10:32:12 +02:00
Anselme FRANÇOIS
343eecb4d8 most fouras commands are now using discord slash commands 2026-07-10 16:03:59 +02:00
Anselme FRANÇOIS
400dcecf13 Fouras module now uses sqlite to remember active riddles 2026-07-07 16:49:39 +02:00
Anselme FRANÇOIS
8b2681eee5 Fixed some spelling errors 2026-04-20 16:47:50 +02:00
Anselme FRANÇOIS
78827521d5 removed overkill module system, rhymes now has logs and a sqlite database 2026-04-20 16:44:15 +02:00
20 changed files with 866 additions and 655 deletions

3
.gitignore vendored
View File

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

View File

@ -10,6 +10,9 @@ 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 .
@ -19,5 +22,8 @@ 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"]

View File

@ -1,29 +1,41 @@
Pour ajouter ce bot à votre serveur discord :
https://discord.com/api/oauth2/authorize?client_id=1110208055171367014&permissions=274877975552&scope=bot
BOT Discord de l'authentique Père Fouras sur votre channel discord, avec plus de 300 de ses fameuses énigmes à vous poser.
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 :
Ce bot est une refonte totale d'un vieux bot IRC que j'avais développé en C++ en 2016 :
https://git.epicsparrow.com/Anselme/SparrowBot/src/branch/master/app/fourasmodule.cpp
# How to run the bot
# Pour les utilisateurs
## Setup env var for the bot
`/fouras` pour demander une énigme
`/indice` pour demander un indice sur l'énigme courante
- 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
`/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)
# 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 main
python -m venv "venv"
source venv/bin/activate
pip install -r requirements.txt
python main.py
```
## Setup discord
Créer un bot et générer un TOKEN sur le portail développeurs :
https://discord.com/developers/applications
## EXPERIMENTAL GITEA API
Permission needed:
- repo: read
- issues: read & write
## 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

View File

@ -1,19 +0,0 @@
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)

185
database.py Normal file
View File

@ -0,0 +1,185 @@
# 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()

14
docker-compose.yml Normal file
View File

@ -0,0 +1,14 @@
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

398
fouras.py Normal file
View File

@ -0,0 +1,398 @@
# 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)))

77
main.py
View File

@ -1,50 +1,63 @@
import discord
import os
from modules.base import BaseModule
from client import client, MODULES, tree
from modules import gitea
import discord
from discord import app_commands
from dotenv import load_dotenv
token = os.getenv("DISCORD_TOKEN", "NO_TOKEN")
load_dotenv()
client.riddles = []
client.answers = []
client.rhyme_keys = {}
client.rhyme_strings = {}
client.cooldown = {}
client.ongoing_riddles = {}
client.modules: list[BaseModule] = []
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)
@client.event
async def on_ready():
client.modules = [m(client) for m in MODULES]
for m in client.modules:
await m.load()
async for guild in client.fetch_guilds():
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)
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):
# 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
global client
if message.author == client.user:
return
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)
from fouras import handle_message as handle_fouras
# Initialise le client
client.run(token)
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)
client.run(token)

View File

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

View File

@ -1,44 +0,0 @@
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
)

View File

@ -1,295 +0,0 @@
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

View File

@ -1,69 +0,0 @@
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)

View File

@ -1,136 +0,0 @@
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

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

View File

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

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é, sculpté elle est exotique
Taillée, sculptée 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 a manœuvrée,
Un bateau ou a sortir un poisson de l'eau.
Cette perche sert à manœuvrer un bateau,
Ou à 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 un clé
Pour la police, ils sont une 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, née l'essence
A une couleur elle a donnée naissance.
De son cœur, nait l'essence
À une couleur elle a donné 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é
On aime cette peau si parfumée
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 acheminée bien du courrier
Autrefois elle acheminait 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é
Parfois mauvaise et redoutée
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 çà vaut.
Moins vous en faites et mieux ça vaut.
Qu'il soit décoré ou peint,
Chaque noble a le sien.

132
rhymes.py Normal file
View File

@ -0,0 +1,132 @@
# 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)