first commit
This commit is contained in:
21
.env
Normal file
21
.env
Normal file
@@ -0,0 +1,21 @@
|
||||
# Flask
|
||||
SECRET_KEY=dev_secret
|
||||
DATABASE_URL=sqlite:///treasure.db
|
||||
|
||||
# IA Perplexity
|
||||
PERPLEXITY_API_KEY=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
|
||||
|
||||
# Matrix
|
||||
MATRIX_HOMESERVER=https://conduit.blackdrop.fr
|
||||
MATRIX_USER=@chatbot:conduit.blackdrop.fr
|
||||
MATRIX_PASSWORD=">J?e3n7~c)Mc#xq"
|
||||
MATRIX_ACCESS_TOKEN="TSKq7w3oygdgLuhTJ31LlpfDrtTO0fLI" # de préférence utiliser un token
|
||||
MATRIX_ROOM_ID="!__cfA9pLoT-ar8Jpje1hrdt8n7ngzOYeg_dyuho3ytA"
|
||||
|
||||
# Sécurité API
|
||||
FLASK_SHARED_SECRET=secret_flask_matrix
|
||||
|
||||
# Options
|
||||
DEBUG=true
|
||||
|
||||
GOOGLE_SHEET_CSV_URL = "https://docs.google.com/spreadsheets/d/1H5rs2wR2Hb1GYhMwEjxOWtP5AnZgdIOX3Xmag_RLCBs/export?format=csv"
|
||||
0
__init.py__
Normal file
0
__init.py__
Normal file
BIN
__pycache__/app.cpython-312.pyc
Normal file
BIN
__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/config.cpython-312.pyc
Normal file
BIN
__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/models.cpython-312.pyc
Normal file
BIN
__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
801
app.py
Normal file
801
app.py
Normal file
@@ -0,0 +1,801 @@
|
||||
import os
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from dotenv import load_dotenv
|
||||
import pandas as pd
|
||||
from models import db, Player, Step, MessageLog
|
||||
import requests
|
||||
import logging
|
||||
from io import StringIO
|
||||
import re
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger("flask")
|
||||
|
||||
# Charger .env
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev_secret")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv("DATABASE_URL", "sqlite:///treasure.db")
|
||||
db.init_app(app)
|
||||
|
||||
PERPLEXITY_API_KEY = os.getenv("PERPLEXITY_API_KEY")
|
||||
FLASK_SHARED_SECRET = os.getenv("FLASK_SHARED_SECRET", "secret_flask_matrix")
|
||||
CONTEXT_TURNS = int(os.getenv("CONTEXT_TURNS", 10)) # nbre d'échanges pris en compte
|
||||
GOOGLE_SHEET_CSV_URL = os.getenv("GOOGLE_SHEET_CSV_URL", "")
|
||||
|
||||
def build_perplexity_history(player, current_user_message, step):
|
||||
# récupère tout l'historique du joueur, ordonné
|
||||
history = MessageLog.query.filter_by(player_id=player.id).order_by(MessageLog.timestamp.asc()).all()
|
||||
messages = []
|
||||
for msg in history:
|
||||
if msg.sender == "Katniss":
|
||||
messages.append({"role": "assistant", "content": msg.content})
|
||||
else:
|
||||
messages.append({"role": "user", "content": msg.content})
|
||||
|
||||
# On ajoute le message courant et le contexte de l'étape dans UN SEUL message user à la fin
|
||||
context_prefix = (
|
||||
f"[Contexte: Étape {step.step if step else '?'} - {step.location if step else ''}]\n"
|
||||
f"Énigme: {step.location_enigma if step else ''}\n"
|
||||
)
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": context_prefix + current_user_message.strip()
|
||||
})
|
||||
|
||||
# Nettoie pour garantir alternance
|
||||
cleaned = []
|
||||
last_role = None
|
||||
for m in messages:
|
||||
if m["role"] == last_role:
|
||||
# Fusionne le contenu avec le dernier du même role (précaution très utile)
|
||||
cleaned[-1]["content"] += "\n\n" + m["content"]
|
||||
else:
|
||||
cleaned.append(m)
|
||||
last_role = m["role"]
|
||||
|
||||
# Ancien prompt_system remplacé pour prendre en compte le model Step
|
||||
prompt_system = (
|
||||
"Tu es Katniss, guide d'aventure pour une chasse au trésor (rôle assistant). "
|
||||
"Tu reçois toujours, en contexte, un 'step' qui contient les champs suivants :\n"
|
||||
"- step : numéro de l'étape\n"
|
||||
"- pre_text : texte d'introduction/context avant saisie du code (si présent)\n"
|
||||
"- location : titre du lieu\n"
|
||||
"- location_enigma : énigme principale ou description de l'énigme à créer\n"
|
||||
"- location_hint : indice lié au code (à utiliser progressivement)\n"
|
||||
"- code : la réponse/código (NE JAMAIS révéler au joueur)\n"
|
||||
"- question : question additionnelle éventuelle\n"
|
||||
"- question_hint : indice pour la question\n"
|
||||
"- answer : réponse à la question (NE JAMAIS révéler)\n"
|
||||
"- comments : commentaire pour le MJ (informations internes)\n"
|
||||
"- success_text : texte à afficher après réussite\n"
|
||||
"- image_path, audio_path : chemins vers médias liés à l'étape (mention possible)\n\n"
|
||||
"Règles strictes :\n"
|
||||
"1) Ne jamais fournir directement 'code' ni 'answer'. Si le joueur demande la réponse, refuse poliment et propose un indice.\n"
|
||||
"2) Proposer des indices progressifs : commencer par des indices généraux (réutiliser location_hint ou question_hint), "
|
||||
"puis, si le joueur insiste, donner des indices de plus en plus directs sans révéler la solution.\n"
|
||||
"3) Utiliser pre_text pour contextualiser la demande si présent, et suggérer au joueur d'examiner image_path/audio_path si fournis.\n"
|
||||
"4) Si la requête montre que le joueur a trouvé le code, encourager et rappeler qu'après validation le texte success_text sera affiché.\n"
|
||||
"5) Ne pas inventer d'informations non présentes dans 'step'; se limiter aux champs et à l'historique des messages.\n"
|
||||
"6) Répondre en français, ton amical et encourageant, bref si l'utilisateur demande un indice simple, plus explicite si l'utilisateur a tenté plusieurs fois.\n"
|
||||
)
|
||||
messages_final = [{"role": "system", "content": prompt_system}] + cleaned[-20:] # Limite à 20 messages récents
|
||||
return messages_final
|
||||
|
||||
|
||||
def get_ai_hint(player, current_user_message):
|
||||
step = Step.query.filter_by(step=player.current_step).first()
|
||||
messages = build_perplexity_history(player, current_user_message, step)
|
||||
log.info(messages)
|
||||
url = "https://api.perplexity.ai/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": "sonar",
|
||||
"messages": messages,
|
||||
"max_tokens": 500,
|
||||
"temperature": 0.7,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=20)
|
||||
if resp.status_code == 400:
|
||||
app.logger.error(f"Perplexity 400: {resp.text}")
|
||||
return "(Katniss indisponible: historique de messages mal formé, regarde les logs serveur !)"
|
||||
resp.raise_for_status()
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
return content.strip() if content else "(Katniss n'a rien répondu, elle ne capte probablement plus au fond du bunker)"
|
||||
except Exception as e:
|
||||
return f"(Katniss est en échec: {e})"
|
||||
|
||||
|
||||
def call_perplexity(messages, max_tokens=400, temperature=0.7):
|
||||
"""Send messages to Perplexity and return assistant content or error string."""
|
||||
url = "https://api.perplexity.ai/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": "sonar",
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=20)
|
||||
if resp.status_code == 400:
|
||||
app.logger.error(f"Perplexity 400: {resp.text}")
|
||||
return "(Katniss indisponible: historique de messages mal formé, regarde les logs serveur !)"
|
||||
resp.raise_for_status()
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
return content.strip() if content else ""
|
||||
except Exception as e:
|
||||
app.logger.exception("Perplexity error")
|
||||
return f"(Katniss est en échec: {e})"
|
||||
|
||||
|
||||
def is_affirmative(text: str) -> bool:
|
||||
"""Return True if text looks like a positive/ready answer (oui/ok/yes/etc.)."""
|
||||
if not text:
|
||||
return False
|
||||
normalized = re.sub(r"[^a-z0-9\s]", "", text.lower())
|
||||
tokens = set(normalized.split())
|
||||
affirmatives = {"oui", "ouais", "ok", "yes", "yeah", "yep", "go", "pret", "prêt", "daccord", "d'accord"}
|
||||
return len(tokens & affirmatives) > 0
|
||||
|
||||
|
||||
def matches_any(text: str, choices: str) -> bool:
|
||||
"""Return True if text matches any of the semicolon-separated choices (case-insensitive).
|
||||
|
||||
Normalises by lowercasing, trimming and removing punctuation for a more forgiving match.
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
if not choices:
|
||||
return False
|
||||
text_norm = re.sub(r"[^a-z0-9\s]", "", text.lower()).strip()
|
||||
for part in str(choices).split(";"):
|
||||
part_norm = re.sub(r"[^a-z0-9\s]", "", part.lower()).strip()
|
||||
if part_norm == text_norm:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# def generate_step_intro(player, step):
|
||||
# """Ask the AI to improve pre_text + location_enigma and return a short intro in French."""
|
||||
# if not step:
|
||||
# return "(Aucune étape trouvée)"
|
||||
# system = (
|
||||
# "Tu es Katniss, guide d'aventure et maître du jeu pour une chasse au trésor."
|
||||
# "Améliore et synthétise en français le texte d'introduction fourni (pre_text) et l'énigme (location_enigma) "
|
||||
# "pour qu'il soit engageant et clair pour le joueur. Ne révèle jamais le 'code' ni la 'answer'."
|
||||
# "Parle à la première personne du singulier, comme si tu parlais directement au joueur."
|
||||
# "Renvoie la réponse au format markdown."
|
||||
# )
|
||||
# user_content = f"Prétexte:\n{step.pre_text or ''}\n\nÉnigme:\n{step.location_enigma or ''}\n\n"
|
||||
# messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
|
||||
# return call_perplexity(messages, max_tokens=1500)
|
||||
|
||||
def generate_step_intro(player, step):
|
||||
"""Ask the AI to improve pre_text + location_enigma and return a short intro in French."""
|
||||
if not step:
|
||||
return "(Aucune étape trouvée)"
|
||||
|
||||
system = (
|
||||
"Tu es Katniss, guide d'aventure et maître du jeu pour une chasse au trésor."
|
||||
"Améliore et synthétise en frle texte d'introduction fourni (pre_text) et l'énigme (location_enigma) "
|
||||
"pour qu'il soit engageant et clair pour le joueur. Ne révèle jamais le 'code' ni la 'answer'."
|
||||
"Parle à la première personne du singulier, comme si tu parlais directement au joueur."
|
||||
"Renvoie la réponse au format markdown."
|
||||
)
|
||||
user_content = f"Prétexte:\n{step.pre_text or ''}\n\nÉnigme:\n{step.location_enigma or ''}\n\n"
|
||||
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
|
||||
return call_perplexity(messages, max_tokens=1500)
|
||||
|
||||
|
||||
def generate_intro_text(player, step):
|
||||
"""Generate only the intro (pre_text) improved by AI, without revealing the enigma.
|
||||
Returns markdown string."""
|
||||
if not step:
|
||||
return "(Aucune étape trouvée)"
|
||||
# return step.pre_text or ""
|
||||
system = (
|
||||
"Renvoi le texte d'introduction fourni (pre_text) au format markdown sans fautes mais ne le modifie pas. Ne rajoute pas d'autre informations. "
|
||||
"")
|
||||
user_content = f"pre_text:\n{step.pre_text or ''}\n\n"
|
||||
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
|
||||
return call_perplexity(messages, max_tokens=800)
|
||||
|
||||
|
||||
def generate_enigma_text(player, step):
|
||||
"""Generate only the enigma (location_enigma) improved by AI, ready to be presented to the player."""
|
||||
if not step:
|
||||
return "(Aucune étape trouvée)"
|
||||
# return step.location_enigma or ""
|
||||
system = (
|
||||
"Renvoie l'énigme (location_enigma) au format markdown sans fautes mais ne la modifie pas. Ne rajoute pas d'autre informations."
|
||||
""
|
||||
)
|
||||
user_content = f"Énigme brute:\n{step.location_enigma or ''}\n\n"
|
||||
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
|
||||
return call_perplexity(messages, max_tokens=1000)
|
||||
|
||||
|
||||
def is_player_ready_ai(player, reply_text, step):
|
||||
"""Ask the AI to judge whether the player's reply indicates readiness.
|
||||
|
||||
Returns (bool, katniss_message). The AI is instructed to output first line TRUE or FALSE, then a short encouraging message on the next line.
|
||||
"""
|
||||
system = (
|
||||
"Tu es Katniss, guide d'aventure. Tu dois lire la réponse courte d'un joueur et décider s'il est prêt à recevoir l'énigme suivante. "
|
||||
"Réponds STRICTEMENT sur deux lignes : première ligne TRUE si le joueur est prêt, FALSE sinon ; deuxième ligne un court message d'encouragement en français (1-2 phrases)."
|
||||
)
|
||||
user_content = f"Contexte étape {step.step if step else '?'} - lieu: {step.location if step else ''}\nRéponse du joueur:\n{reply_text}\n\nRenvoie exactement deux lignes : TRUE/FALSE, puis le message Katniss."
|
||||
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
|
||||
ai_text = call_perplexity(messages, max_tokens=200)
|
||||
if not ai_text:
|
||||
return False, "Prends ton temps, dis-moi quand tu seras prêt·e."
|
||||
# Parse first token for true/false
|
||||
first_line = ai_text.splitlines()[0].strip().lower() if ai_text else ""
|
||||
kat_msg = "\n".join(ai_text.splitlines()[1:]).strip() or "Prends ton temps, dis-moi quand tu seras prêt·e."
|
||||
ready = False
|
||||
if "true" in first_line or "oui" in first_line or "vrai" in first_line:
|
||||
ready = True
|
||||
return ready, kat_msg
|
||||
|
||||
|
||||
def generate_formatted_hint(player, step, hint_text, hint_kind="location", hint_index=0):
|
||||
"""Ask the AI to present a single hint (already extracted) in a friendly way."""
|
||||
system = (
|
||||
"Tu es Katniss, guide d'aventure et maitre du jeu pour Magali. Renvoie l'indice (hint) SANS MODIFICATION, sans fautes, sans autres informations"
|
||||
"Avant l'indice, encourage le joueur."
|
||||
)
|
||||
user_content = (
|
||||
f"indice : {hint_text}"
|
||||
""
|
||||
)
|
||||
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
|
||||
return call_perplexity(messages, max_tokens=1000)
|
||||
|
||||
|
||||
def generate_question_text(player:Player, step:Step):
|
||||
"""Ask the AI to format the question to the player if present."""
|
||||
if not step or not step.question:
|
||||
return ""
|
||||
system = (
|
||||
"Tu es Katniss, guide d'aventure et maitre du jeu. Reformule la question fournie de façon claire et engageante en français. "
|
||||
"Parle à la première personne du singulier, comme si tu parlais directement au joueur."
|
||||
"Renvoie la réponse au format markdown."
|
||||
"Les questions sont liés à des évenements entre Sam et Magali"
|
||||
)
|
||||
user_content = f"Question brute:\n{step.question}\n\nRenvoie la question après avoir donner un encouragement pour avoir réussi à trouver le code. indique en gras que c'est une question. redonne le numero de l'etape {step.step}"
|
||||
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
|
||||
return call_perplexity(messages, max_tokens=1000)
|
||||
|
||||
|
||||
def generate_success_text(player, step):
|
||||
"""Ask the AI to produce an encouraging success message (can use step.success_text as base)."""
|
||||
base = step.success_text or "Bravo ! Tu as réussi cette étape."
|
||||
|
||||
# system = (
|
||||
# "Tu es Katniss, guide d'aventure. Génère un court message d'encouragement en français à destination du joueur après réussite. "
|
||||
# "Tu peux améliorer le texte de base sans révéler d'informations supplémentaires."
|
||||
# "garde les élements entre crochets dans le texte de base"
|
||||
# "Parle à la première personne du singulier, comme si tu parlais directement au joueur."
|
||||
# "Renvoie la réponse au format markdown."
|
||||
# )
|
||||
system = (
|
||||
"Renvoie le texte d'encouragement fourni (success_text) au format markdown sans fautes mais ne le modifie pas. Ne rajoute pas d'autre informations. "
|
||||
"")
|
||||
# Ask the AI to enhance the base text but ensure we keep the original base (with bracketed URLs)
|
||||
# user_content = (
|
||||
# f"Texte de base:\n{base}\n\n"
|
||||
# "Améliore ce texte de base pour en faire un court message d'encouragement chaleureux en français."
|
||||
# " NE MODIFIE PAS ni ne SUPPRIME les éléments entre crochets [] présents dans le texte de base."
|
||||
# " Renvoie uniquement le texte d'encouragement (format markdown)."
|
||||
# )
|
||||
user_content = f"success_text:\n{base}\n\n"
|
||||
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_content}]
|
||||
ai_response = call_perplexity(messages, max_tokens=1000)
|
||||
# Always return the original base first (to preserve bracketed URLs), then the AI enhancement
|
||||
# If AI returned an empty string, fall back to base
|
||||
if not ai_response or ai_response.strip() == "":
|
||||
return base
|
||||
return f"{ai_response.strip()}"
|
||||
|
||||
def fetch_steps_from_gsheet(csv_url=GOOGLE_SHEET_CSV_URL):
|
||||
resp = requests.get(csv_url)
|
||||
resp.raise_for_status()
|
||||
csv_content = resp.text
|
||||
|
||||
df = pd.read_csv(StringIO(csv_content))
|
||||
df = df.fillna("") # Remplace NaN par ""
|
||||
steps = []
|
||||
for _, row in df.iterrows():
|
||||
# Only accept the exact CSV columns: step, pre_text, location, location_enigma,
|
||||
# location_hint, code, question, question_hint, answer, comments, success_text
|
||||
raw_step = row.get('step', "")
|
||||
try:
|
||||
number = int(float(raw_step)) if str(raw_step).strip() != "" else None
|
||||
except Exception:
|
||||
# skip invalid/empty step rows
|
||||
continue
|
||||
if number is None:
|
||||
continue
|
||||
|
||||
pre_text = row.get('pre_text', "")
|
||||
location = row.get('location', "")
|
||||
location_enigma = row.get('location_enigma', "")
|
||||
location_hint = row.get('location_hint', "")
|
||||
code = str(row.get('code', ""))
|
||||
question = row.get('question', "")
|
||||
question_hint = row.get('question_hint', "")
|
||||
answer = str(row.get('answer', ""))
|
||||
comments = row.get('comments', "")
|
||||
success_text = row.get('success_text', "")
|
||||
|
||||
# No media columns expected in this simplified mapping
|
||||
image_path = ""
|
||||
audio_path = ""
|
||||
|
||||
step = Step(
|
||||
step=number,
|
||||
pre_text=pre_text,
|
||||
location=location,
|
||||
location_enigma=location_enigma,
|
||||
location_hint=location_hint,
|
||||
code=code,
|
||||
question=question,
|
||||
question_hint=question_hint,
|
||||
answer=answer,
|
||||
comments=comments,
|
||||
success_text=success_text,
|
||||
image_path=image_path,
|
||||
audio_path=audio_path,
|
||||
)
|
||||
steps.append(step)
|
||||
return steps
|
||||
|
||||
|
||||
@app.route("/api/matrix/incoming", methods=["POST"])
|
||||
def matrix_incoming():
|
||||
data = request.json
|
||||
if data.get("secret") != FLASK_SHARED_SECRET:
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
|
||||
matrix_id = data.get("sender")
|
||||
text = data.get("body", "").strip()
|
||||
|
||||
player = Player.query.filter_by(matrix_id=matrix_id).first()
|
||||
is_new_player = False
|
||||
if not player:
|
||||
# initialize player with hint counters and stage (awaiting ready confirmation)
|
||||
player = Player(
|
||||
matrix_id=matrix_id,
|
||||
current_step=1,
|
||||
stage='awaiting_ready',
|
||||
location_hint_index=0,
|
||||
question_hint_index=0,
|
||||
last_sent_step=None,
|
||||
)
|
||||
db.session.add(player)
|
||||
db.session.commit()
|
||||
is_new_player = True
|
||||
|
||||
# Log message reçu
|
||||
db.session.add(MessageLog(player_id=player.id, sender="mag", content=text))
|
||||
|
||||
step = Step.query.filter_by(step=player.current_step).first()
|
||||
|
||||
# If no step found, respond accordingly
|
||||
if not step:
|
||||
reply = "(Aucune étape définie pour ce joueur pour le moment.)"
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": reply})
|
||||
|
||||
# Ensure player has stage/hint counters attributes (in case models were not migrated)
|
||||
if not hasattr(player, 'stage') or not player.stage:
|
||||
player.stage = 'intro_needed'
|
||||
if not hasattr(player, 'location_hint_index'):
|
||||
player.location_hint_index = 0
|
||||
if not hasattr(player, 'question_hint_index'):
|
||||
player.question_hint_index = 0
|
||||
|
||||
reply = None
|
||||
|
||||
# If player is awaiting the "ready to play" confirmation
|
||||
if player.stage == 'awaiting_ready':
|
||||
# Use AI to judge whether the player's message indicates readiness and get a short Katniss reply
|
||||
ready, kat_msg = is_player_ready_ai(player, text, step)
|
||||
if ready:
|
||||
# Prepare and send intro for the current step
|
||||
player.stage = 'intro_needed'
|
||||
db.session.commit()
|
||||
if not step:
|
||||
reply = "(Aucune étape définie pour le moment.)"
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": reply})
|
||||
# Instead of sending enigma now, send only the improved intro and ask readiness for enigma
|
||||
intro = generate_intro_text(player, step)
|
||||
player.stage = 'awaiting_ready_for_enigma'
|
||||
player.location_hint_index = 0
|
||||
player.question_hint_index = 0
|
||||
player.last_sent_step = player.current_step
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=intro))
|
||||
db.session.commit()
|
||||
# Prompt the user to confirm when they're ready to see the enigma
|
||||
ready_prompt = "T'es-tu prête pour l'énigme ? "
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": kat_msg + "\n\n" + intro + "\n\n" + ready_prompt})
|
||||
else:
|
||||
# AI says not ready: send Katniss encouraging message
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": kat_msg})
|
||||
|
||||
# If player answered the intro and we are waiting for them to confirm readiness for the enigma
|
||||
if player.stage == 'awaiting_ready_for_enigma':
|
||||
# Use AI to interpret readiness and get Katniss encouragement
|
||||
ready, kat_msg = is_player_ready_ai(player, text, step)
|
||||
|
||||
if ready:
|
||||
# send the enigma (improved by AI) and move to awaiting_code
|
||||
enigma = generate_enigma_text(player, step)
|
||||
player.stage = 'awaiting_code'
|
||||
player.location_hint_index = 0
|
||||
player.question_hint_index = 0
|
||||
player.last_sent_step = player.current_step
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=enigma))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": kat_msg + "\n\n" + enigma})
|
||||
else:
|
||||
# send encouraging message and remain in awaiting_ready_for_enigma
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": kat_msg})
|
||||
|
||||
# If player is awaiting confirmation after a success to continue to next step
|
||||
if player.stage == 'awaiting_ready_after_success':
|
||||
# The current_step was already incremented at success time; get the new step
|
||||
next_step = Step.query.filter_by(step=player.current_step).first()
|
||||
# Use AI to interpret readiness
|
||||
ready, kat_msg = is_player_ready_ai(player, text, next_step)
|
||||
if ready:
|
||||
if not next_step:
|
||||
reply = "(Aucune étape suivante définie.)"
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": reply})
|
||||
# send the intro for the next step (improved by AI) and move to awaiting_ready_for_enigma
|
||||
next_intro = generate_intro_text(player, next_step)
|
||||
player.stage = 'awaiting_ready_for_enigma'
|
||||
player.location_hint_index = 0
|
||||
player.question_hint_index = 0
|
||||
player.last_sent_step = player.current_step
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=next_intro))
|
||||
db.session.commit()
|
||||
ready_prompt = "T'es-tu prête pour l'énigme ?"
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": kat_msg + "\n\n" + next_intro + "\n\n" + ready_prompt})
|
||||
else:
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=kat_msg))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": kat_msg})
|
||||
|
||||
# 1) If we need to send the intro for this step
|
||||
if player.stage == 'intro_needed' or player.current_step != getattr(player, 'last_sent_step', None):
|
||||
intro = generate_intro_text(player, step)
|
||||
# store that we've sent the intro and reset hint counters, but wait for readiness before enigma
|
||||
player.stage = 'awaiting_ready_for_enigma'
|
||||
player.location_hint_index = 0
|
||||
player.question_hint_index = 0
|
||||
player.last_sent_step = player.current_step
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=intro))
|
||||
db.session.commit()
|
||||
ready_prompt = "T'es-tu prête pour l'énigme ?"
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": intro + "\n\n" + ready_prompt})
|
||||
|
||||
# 2) We're waiting for the code
|
||||
if player.stage == 'awaiting_code':
|
||||
if matches_any(text, step.code or ""):
|
||||
# Correct code
|
||||
# If there's a question, send it (formatted by AI) and change stage
|
||||
if step.question and step.question.strip() != "":
|
||||
qtext = generate_question_text(player, step)
|
||||
player.stage = 'awaiting_answer'
|
||||
player.question_hint_index = 0
|
||||
reply = qtext or step.question
|
||||
else:
|
||||
# No question: generate success, advance step and ask ready-to-continue
|
||||
success = generate_success_text(player, step)
|
||||
player.current_step += 1
|
||||
# After success, wait for player's confirmation before sending next intro
|
||||
player.stage = 'awaiting_ready_after_success'
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=success))
|
||||
db.session.commit()
|
||||
# Check if there's a next step; if so, prepare a ready prompt as followup
|
||||
next_step = Step.query.filter_by(step=player.current_step).first()
|
||||
if next_step:
|
||||
# Optionally pre-generate and store the intro for debugging/history
|
||||
next_intro = generate_intro_text(player, next_step)
|
||||
player.last_sent_step = player.current_step
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=next_intro))
|
||||
db.session.commit()
|
||||
ready_prompt = "Bravo ! Veux-tu continuer vers l'étape suivante ?"
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": success, "followup": ready_prompt})
|
||||
else:
|
||||
reply = success + "\n\nTu as terminé toutes les étapes. Félicitations !"
|
||||
db.session.commit()
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": reply})
|
||||
else:
|
||||
# Incorrect code: send next location hint (one at a time)
|
||||
raw_hints = (step.location_hint or "").split(";") if step.location_hint else []
|
||||
idx = int(getattr(player, 'location_hint_index', 0))
|
||||
if idx < len(raw_hints) and raw_hints[idx].strip() != "":
|
||||
hint_raw = raw_hints[idx].strip()
|
||||
hint_text = generate_formatted_hint(player, step, hint_raw, hint_kind='location', hint_index=idx)
|
||||
player.location_hint_index = idx + 1
|
||||
else:
|
||||
# No more location hints available; ask AI to give a gentle generic hint
|
||||
hint_text = generate_formatted_hint(player, step, step.location_hint or "", hint_kind='location', hint_index=idx)
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=hint_text))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": hint_text})
|
||||
|
||||
# 3) We're waiting for the answer to the question
|
||||
if player.stage == 'awaiting_answer':
|
||||
if matches_any(text, step.answer or ""):
|
||||
# Correct answer: generate success and advance
|
||||
success = generate_success_text(player, step)
|
||||
player.current_step += 1
|
||||
# After success, wait for player's confirmation before sending next intro
|
||||
player.stage = 'awaiting_ready_after_success'
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=success))
|
||||
db.session.commit()
|
||||
# send next intro later after confirmation; prepare ready prompt as followup if next exists
|
||||
next_step = Step.query.filter_by(step=player.current_step).first()
|
||||
if next_step:
|
||||
next_intro = generate_step_intro(player, next_step)
|
||||
player.last_sent_step = player.current_step
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=next_intro))
|
||||
db.session.commit()
|
||||
ready_prompt = "Bravo Mag ! T'es tu prête pour la suite ?"
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": success, "followup": ready_prompt})
|
||||
else:
|
||||
reply = success + "\n\nTu as terminé toutes les étapes. Félicitations !"
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": reply})
|
||||
else:
|
||||
# Incorrect answer: provide next question hint (one at a time)
|
||||
raw_qhints = (step.question_hint or "").split(";") if step.question_hint else []
|
||||
qidx = int(getattr(player, 'question_hint_index', 0))
|
||||
if qidx < len(raw_qhints) and raw_qhints[qidx].strip() != "":
|
||||
qhint_raw = raw_qhints[qidx].strip()
|
||||
qhint_text = generate_formatted_hint(player, step, qhint_raw, hint_kind='question', hint_index=qidx)
|
||||
player.question_hint_index = qidx + 1
|
||||
else:
|
||||
qhint_text = generate_formatted_hint(player, step, step.question_hint or "", hint_kind='question', hint_index=qidx)
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=qhint_text))
|
||||
db.session.commit()
|
||||
return jsonify({"reply": qhint_text})
|
||||
|
||||
# Log réponse Katniss
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=reply))
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"reply": reply})
|
||||
|
||||
|
||||
@app.route("/api/admin/reset_player", methods=["POST"])
|
||||
def reset_player():
|
||||
data = request.json or {}
|
||||
secret = data.get("secret")
|
||||
matrix_id = data.get("matrix_id")
|
||||
if secret != FLASK_SHARED_SECRET:
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
if not matrix_id:
|
||||
return jsonify({"error": "matrix_id_required"}), 400
|
||||
player = Player.query.filter_by(matrix_id=matrix_id).first()
|
||||
if not player:
|
||||
# Create player and set initial stage to awaiting_ready so we can prompt them
|
||||
player = Player(
|
||||
matrix_id=matrix_id,
|
||||
current_step=1,
|
||||
stage='awaiting_ready',
|
||||
location_hint_index=0,
|
||||
question_hint_index=0,
|
||||
last_sent_step=None,
|
||||
)
|
||||
db.session.add(player)
|
||||
db.session.commit()
|
||||
# Prepare and log the ready prompt so the bot can forward it to the user
|
||||
ready_prompt = "Partie initialisée. Es-tu prête à commencer les jeux ?"
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
|
||||
db.session.commit()
|
||||
return jsonify({"status": "created_and_reset", "current_step": player.current_step, "reply": ready_prompt})
|
||||
# Efface l’historique messages joueur seulement (pas global)
|
||||
MessageLog.query.filter_by(player_id=player.id).delete()
|
||||
player.current_step = 1
|
||||
# After reset, ask the player if they're ready to start
|
||||
player.stage = 'awaiting_ready'
|
||||
player.location_hint_index = 0
|
||||
player.question_hint_index = 0
|
||||
player.last_sent_step = None
|
||||
db.session.commit()
|
||||
ready_prompt = "Partie réinitialisée. T'es-tu prête à recommencer les jeux ? "
|
||||
# Log prompt
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=ready_prompt))
|
||||
db.session.commit()
|
||||
log.info(f"Player {matrix_id} reset to step 1 and awaiting ready confirmation.")
|
||||
# Return the ready prompt to be sent to the player
|
||||
return jsonify({"status": "reset_ok", "current_step": player.current_step, "reply": ready_prompt})
|
||||
|
||||
@app.route("/api/admin/push_message", methods=["POST"])
|
||||
def admin_push():
|
||||
data = request.json
|
||||
matrix_id = data.get("matrix_id")
|
||||
text = data.get("text")
|
||||
player = Player.query.filter_by(matrix_id=matrix_id).first()
|
||||
if not player:
|
||||
return jsonify({"error": "player_not_found"}), 404
|
||||
db.session.add(MessageLog(player_id=player.id, sender="admin", content=text))
|
||||
db.session.commit()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@app.route("/api/admin/gen_success", methods=["POST"])
|
||||
def gen_success():
|
||||
"""Generate the AI success text for the player's current step (debug endpoint).
|
||||
|
||||
POST JSON: { "secret": <shared_secret>, "matrix_id": "@user:server" }
|
||||
Returns: { status: ok, reply: <text>, current_step: n }
|
||||
"""
|
||||
data = request.json or {}
|
||||
secret = data.get("secret")
|
||||
matrix_id = data.get("matrix_id")
|
||||
if secret != FLASK_SHARED_SECRET:
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
if not matrix_id:
|
||||
return jsonify({"error": "matrix_id_required"}), 400
|
||||
player = Player.query.filter_by(matrix_id=matrix_id).first()
|
||||
if not player:
|
||||
return jsonify({"error": "player_not_found"}), 404
|
||||
step = Step.query.filter_by(step=player.current_step).first()
|
||||
if not step:
|
||||
return jsonify({"error": "step_not_found"}), 404
|
||||
|
||||
success = generate_success_text(player, step)
|
||||
# Log the generated success text for debugging (do not advance the player)
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=success))
|
||||
db.session.commit()
|
||||
return jsonify({"status": "ok", "reply": success, "current_step": player.current_step})
|
||||
|
||||
|
||||
@app.route("/api/admin/gen_question", methods=["POST"])
|
||||
def gen_question():
|
||||
"""Generate the AI question text for the player's current step (debug endpoint)."""
|
||||
data = request.json or {}
|
||||
secret = data.get("secret")
|
||||
matrix_id = data.get("matrix_id")
|
||||
if secret != FLASK_SHARED_SECRET:
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
if not matrix_id:
|
||||
return jsonify({"error": "matrix_id_required"}), 400
|
||||
player = Player.query.filter_by(matrix_id=matrix_id).first()
|
||||
if not player:
|
||||
return jsonify({"error": "player_not_found"}), 404
|
||||
step = Step.query.filter_by(step=player.current_step).first()
|
||||
if not step:
|
||||
return jsonify({"error": "step_not_found"}), 404
|
||||
|
||||
qtext = generate_question_text(player, step)
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=qtext))
|
||||
db.session.commit()
|
||||
return jsonify({"status": "ok", "reply": qtext, "current_step": player.current_step})
|
||||
|
||||
|
||||
@app.route("/api/admin/gen_intro", methods=["POST"])
|
||||
def gen_intro():
|
||||
"""Generate the AI intro text for the player's current step (debug endpoint)."""
|
||||
data = request.json or {}
|
||||
secret = data.get("secret")
|
||||
matrix_id = data.get("matrix_id")
|
||||
if secret != FLASK_SHARED_SECRET:
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
if not matrix_id:
|
||||
return jsonify({"error": "matrix_id_required"}), 400
|
||||
player = Player.query.filter_by(matrix_id=matrix_id).first()
|
||||
if not player:
|
||||
return jsonify({"error": "player_not_found"}), 404
|
||||
step = Step.query.filter_by(step=player.current_step).first()
|
||||
if not step:
|
||||
return jsonify({"error": "step_not_found"}), 404
|
||||
|
||||
intro = generate_step_intro(player, step)
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=intro))
|
||||
db.session.commit()
|
||||
return jsonify({"status": "ok", "reply": intro, "current_step": player.current_step})
|
||||
|
||||
|
||||
@app.route("/api/admin/gen_hint", methods=["POST"])
|
||||
def gen_hint():
|
||||
"""Generate a single formatted hint for the player's current step (debug endpoint).
|
||||
|
||||
POST JSON: { "secret": <shared_secret>, "matrix_id": "@user:server", "hint_kind": "location"|"question", "hint_index": 0, "hint_text": "optional raw hint" }
|
||||
If hint_text is provided it is used directly. Otherwise the endpoint picks the hint at hint_index from the requested hint_kind field.
|
||||
"""
|
||||
data = request.json or {}
|
||||
secret = data.get("secret")
|
||||
matrix_id = data.get("matrix_id")
|
||||
hint_kind = data.get("hint_kind", "location")
|
||||
hint_index = int(data.get("hint_index", 0) or 0)
|
||||
hint_text_override = data.get("hint_text")
|
||||
|
||||
if secret != FLASK_SHARED_SECRET:
|
||||
return jsonify({"error": "Forbidden"}), 403
|
||||
if not matrix_id:
|
||||
return jsonify({"error": "matrix_id_required"}), 400
|
||||
player = Player.query.filter_by(matrix_id=matrix_id).first()
|
||||
if not player:
|
||||
return jsonify({"error": "player_not_found"}), 404
|
||||
step = Step.query.filter_by(step=player.current_step).first()
|
||||
if not step:
|
||||
return jsonify({"error": "step_not_found"}), 404
|
||||
|
||||
if hint_text_override and str(hint_text_override).strip() != "":
|
||||
hint_raw = str(hint_text_override).strip()
|
||||
else:
|
||||
if hint_kind == 'question':
|
||||
raw_list = (step.question_hint or "").split(";") if step.question_hint else []
|
||||
else:
|
||||
raw_list = (step.location_hint or "").split(";") if step.location_hint else []
|
||||
if 0 <= hint_index < len(raw_list):
|
||||
hint_raw = raw_list[hint_index].strip()
|
||||
else:
|
||||
# fallback to full hint field
|
||||
hint_raw = (step.question_hint if hint_kind == 'question' else step.location_hint) or ""
|
||||
|
||||
hint_out = generate_formatted_hint(player, step, hint_raw, hint_kind=hint_kind, hint_index=hint_index)
|
||||
db.session.add(MessageLog(player_id=player.id, sender="Katniss", content=hint_out))
|
||||
db.session.commit()
|
||||
return jsonify({"status": "ok", "reply": hint_out, "current_step": player.current_step})
|
||||
|
||||
# ... Commande init-db inchangée ...
|
||||
|
||||
|
||||
# --------- Init DB ----------
|
||||
@app.cli.command("init-db")
|
||||
def init_db():
|
||||
db.drop_all()
|
||||
db.create_all()
|
||||
steps = fetch_steps_from_gsheet()
|
||||
for step in steps:
|
||||
db.session.add(step)
|
||||
db.session.commit()
|
||||
print(f"{len(steps)} étapes importées avec succès depuis Google Sheet !")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
app.run(host="0.0.0.0", port=5000, debug=os.getenv("DEBUG", "false").lower() == "true")
|
||||
365
bot.py
Normal file
365
bot.py
Normal file
@@ -0,0 +1,365 @@
|
||||
import os
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from nio import AsyncClient, LoginResponse, RoomMessageText
|
||||
import markdown2
|
||||
import re
|
||||
import mimetypes
|
||||
import io
|
||||
from urllib.parse import urlparse
|
||||
import functools
|
||||
import coloredlogs
|
||||
|
||||
load_dotenv()
|
||||
|
||||
HOMESERVER = os.getenv("MATRIX_HOMESERVER")
|
||||
USER = os.getenv("MATRIX_USER")
|
||||
PASSWORD = os.getenv("MATRIX_PASSWORD")
|
||||
ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN")
|
||||
ROOM_ID = os.getenv("MATRIX_ROOM_ID")
|
||||
STORE_PATH = os.getenv("MATRIX_STORE_PATH", ".matrixstore")
|
||||
FLASK_INCOMING = os.getenv("FLASK_ENDPOINT_INCOMING", "http://localhost:5000/api/matrix/incoming")
|
||||
FLASK_SHARED_SECRET = os.getenv("FLASK_SHARED_SECRET", "secret_flask_matrix")
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger("matrix-bot")
|
||||
# install coloredlogs for nicer colored output in terminals
|
||||
try:
|
||||
coloredlogs.install(level=logging.INFO, logger=log, fmt='%(asctime)s %(levelname)s %(message)s')
|
||||
except Exception:
|
||||
# coloredlogs optional; fallback silently
|
||||
pass
|
||||
|
||||
class MatrixBot:
|
||||
def __init__(self):
|
||||
self.client = AsyncClient(HOMESERVER, USER, store_path=STORE_PATH)
|
||||
if ACCESS_TOKEN:
|
||||
self.client.access_token = ACCESS_TOKEN
|
||||
self.client.user_id = USER
|
||||
self.client.add_event_callback(self.message_callback, RoomMessageText)
|
||||
# timestamp (ms) when the bot considers "connected"; events older than this are ignored
|
||||
self.start_ts = 0
|
||||
|
||||
async def login(self):
|
||||
if not ACCESS_TOKEN:
|
||||
resp = await self.client.login(PASSWORD, device_name="LiaBot")
|
||||
if isinstance(resp, LoginResponse):
|
||||
log.info("Connecté en tant que %s", resp.user_id)
|
||||
# mark connection time to discard past history (epoch ms)
|
||||
self.start_ts = int(time.time() * 1000)
|
||||
else:
|
||||
raise RuntimeError(f"Login failed: {resp}")
|
||||
else:
|
||||
# if using ACCESS_TOKEN we still consider now as the connection time
|
||||
self.start_ts = int(time.time() * 1000)
|
||||
|
||||
async def message_callback(self, room, event):
|
||||
# ignore historical events that arrived before the bot connected
|
||||
try:
|
||||
evt_ts = None
|
||||
for attr in ("server_timestamp", "server_ts", "origin_server_ts", "server_time", "ts"):
|
||||
if hasattr(event, attr):
|
||||
evt_ts = getattr(event, attr)
|
||||
break
|
||||
# Some nio events expose integer ms, some expose nested dicts; try to coerce
|
||||
if evt_ts is None and hasattr(event, "__dict__"):
|
||||
evt_ts = getattr(event, "__dict__", {}).get("server_timestamp")
|
||||
if evt_ts and self.start_ts and int(evt_ts) < int(self.start_ts):
|
||||
log.debug("Ignoring historical event (ts %s < start %s)", evt_ts, self.start_ts)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
log.error("received")
|
||||
if event.sender != "@magali:conduit.blackdrop.fr":
|
||||
return
|
||||
body = (event.body or "").strip()
|
||||
# Commande admin reset
|
||||
if body.startswith("/reset"):
|
||||
parts = body.split()
|
||||
if len(parts) == 2:
|
||||
target = parts[1]
|
||||
else:
|
||||
target = event.sender
|
||||
try:
|
||||
resp = requests.post(
|
||||
os.getenv("FLASK_ENDPOINT_RESET_PLAYER", "http://localhost:5000/api/admin/reset_player"),
|
||||
json={
|
||||
"secret": FLASK_SHARED_SECRET,
|
||||
"matrix_id": target
|
||||
},
|
||||
timeout=15
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
body = resp.json()
|
||||
reply = body.get("reply")
|
||||
if reply:
|
||||
await self.send_message(room.room_id, reply)
|
||||
else:
|
||||
await self.send_message(room.room_id, f"Reset de {target} effectué. Bon nouveau départ !")
|
||||
except Exception:
|
||||
await self.send_message(room.room_id, f"Reset de {target} effectué. Bon nouveau départ ! (Exception)")
|
||||
else:
|
||||
await self.send_message(room.room_id, f"Reset impossible ({resp.status_code}).")
|
||||
except Exception as e:
|
||||
await self.send_message(room.room_id, f"Erreur reset: {e}")
|
||||
return
|
||||
|
||||
# Commande admin: regenere le texte de success pour debug
|
||||
if body.startswith("/gen_success"):
|
||||
parts = body.split()
|
||||
target = parts[1] if len(parts) == 2 else event.sender
|
||||
try:
|
||||
resp = requests.post(
|
||||
os.getenv("FLASK_ENDPOINT_GEN_SUCCESS", "http://localhost:5000/api/admin/gen_success"),
|
||||
json={"secret": FLASK_SHARED_SECRET, "matrix_id": target},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
reply = resp.json().get("reply") or resp.text
|
||||
except Exception:
|
||||
reply = resp.text
|
||||
else:
|
||||
reply = f"gen_success failed ({resp.status_code})"
|
||||
except Exception as e:
|
||||
reply = f"Erreur gen_success: {e}"
|
||||
await self.send_message(room.room_id, reply)
|
||||
return
|
||||
|
||||
# Commande admin: regenere la question pour debug
|
||||
if body.startswith("/gen_question"):
|
||||
parts = body.split()
|
||||
target = parts[1] if len(parts) == 2 else event.sender
|
||||
try:
|
||||
resp = requests.post(
|
||||
os.getenv("FLASK_ENDPOINT_GEN_QUESTION", "http://localhost:5000/api/admin/gen_question"),
|
||||
json={"secret": FLASK_SHARED_SECRET, "matrix_id": target},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
reply = resp.json().get("reply") or resp.text
|
||||
except Exception:
|
||||
reply = resp.text
|
||||
else:
|
||||
reply = f"gen_question failed ({resp.status_code})"
|
||||
except Exception as e:
|
||||
reply = f"Erreur gen_question: {e}"
|
||||
await self.send_message(room.room_id, reply)
|
||||
return
|
||||
|
||||
# Commande admin: regenere l'intro pour debug
|
||||
if body.startswith("/gen_intro"):
|
||||
parts = body.split()
|
||||
target = parts[1] if len(parts) == 2 else event.sender
|
||||
try:
|
||||
resp = requests.post(
|
||||
os.getenv("FLASK_ENDPOINT_GEN_INTRO", "http://localhost:5000/api/admin/gen_intro"),
|
||||
json={"secret": FLASK_SHARED_SECRET, "matrix_id": target},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
reply = resp.json().get("reply") or resp.text
|
||||
except Exception:
|
||||
reply = resp.text
|
||||
else:
|
||||
reply = f"gen_intro failed ({resp.status_code})"
|
||||
except Exception as e:
|
||||
reply = f"Erreur gen_intro: {e}"
|
||||
await self.send_message(room.room_id, reply)
|
||||
return
|
||||
|
||||
# Commande admin: regenere un hint pour debug
|
||||
if body.startswith("/gen_hint"):
|
||||
parts = body.split()
|
||||
target = parts[1] if len(parts) == 2 else event.sender
|
||||
try:
|
||||
resp = requests.post(
|
||||
os.getenv("FLASK_ENDPOINT_GEN_HINT", "http://localhost:5000/api/admin/gen_hint"),
|
||||
json={"secret": FLASK_SHARED_SECRET, "matrix_id": target},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
reply = resp.json().get("reply") or resp.text
|
||||
except Exception:
|
||||
reply = resp.text
|
||||
else:
|
||||
reply = f"gen_hint failed ({resp.status_code})"
|
||||
except Exception as e:
|
||||
reply = f"Erreur gen_hint: {e}"
|
||||
await self.send_message(room.room_id, reply)
|
||||
return
|
||||
|
||||
# Commande admin: regenere le texte de success pour debug
|
||||
if body.startswith("/gen_success"):
|
||||
parts = body.split()
|
||||
if len(parts) == 2:
|
||||
target = parts[1]
|
||||
else:
|
||||
target = event.sender
|
||||
try:
|
||||
resp = requests.post(
|
||||
os.getenv("FLASK_ENDPOINT_GEN_SUCCESS", "http://localhost:5000/api/admin/gen_success"),
|
||||
json={"secret": FLASK_SHARED_SECRET, "matrix_id": target},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
body_json = resp.json()
|
||||
reply = body_json.get("reply") or body_json.get("success") or "(Pas de réponse)"
|
||||
except Exception:
|
||||
reply = resp.text or "(Réponse invalide du serveur)"
|
||||
else:
|
||||
reply = f"gen_success impossible ({resp.status_code})."
|
||||
except Exception as e:
|
||||
reply = f"Erreur gen_success: {e}"
|
||||
await self.send_message(room.room_id, reply)
|
||||
return
|
||||
|
||||
log.info(f"Message de {event.sender}: {event.body}")
|
||||
payload = {
|
||||
"sender": event.sender,
|
||||
"body": event.body,
|
||||
"room_id": room.room_id,
|
||||
"secret": FLASK_SHARED_SECRET
|
||||
}
|
||||
try:
|
||||
resp = requests.post(FLASK_INCOMING, json=payload, timeout=15)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
reply = body.get("reply", "(Pas de réponse)")
|
||||
followup = body.get("followup")
|
||||
except Exception as e:
|
||||
log.error(f"Erreur appel Flask: {e}")
|
||||
reply = "(Erreur Lia)"
|
||||
await self.send_message(room.room_id, reply)
|
||||
if followup:
|
||||
# small pause then send followup as a separate message
|
||||
await asyncio.sleep(1.0)
|
||||
await self.send_message(room.room_id, followup)
|
||||
|
||||
async def send_message(self, room_id, text):
|
||||
|
||||
# Extract bracketed URLs like [https://...] to send as media later
|
||||
matches = re.findall(r"\[(https?://[^\]\s]+)\]", text)
|
||||
|
||||
# Remove bracketed URLs from the chat text
|
||||
cleaned_text = re.sub(r"\[(https?://[^\]\s]+)\]", "", text).strip()
|
||||
if cleaned_text == "":
|
||||
# If nothing left, keep original text as a fallback (so user still sees context)
|
||||
cleaned_text = text
|
||||
|
||||
# Convert cleaned markdown to HTML
|
||||
try:
|
||||
text_html = markdown2.markdown(cleaned_text)
|
||||
except Exception:
|
||||
text_html = (cleaned_text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\n", "<br>"))
|
||||
|
||||
await self.client.room_send(
|
||||
room_id,
|
||||
message_type="m.room.message",
|
||||
content={"msgtype": "m.text", "body": cleaned_text, "format": "org.matrix.custom.html", "formatted_body": text_html}
|
||||
)
|
||||
|
||||
# Send each extracted URL as a separate image/media event
|
||||
for url in matches:
|
||||
if not url:
|
||||
continue
|
||||
# small pause to avoid hammering the homeserver
|
||||
await asyncio.sleep(0.3)
|
||||
await self.send_media(room_id, url)
|
||||
|
||||
async def send_media(self, room_id, url):
|
||||
"""Download the media at `url`, upload it to the Matrix media repo and send an m.image event.
|
||||
|
||||
Supports Google Drive share links by converting them to a direct download URL.
|
||||
If download or upload fails, falls back to sending an m.room.message with the external URL.
|
||||
"""
|
||||
try:
|
||||
# Convert Google Drive share URL to direct download URL if applicable
|
||||
drive_match = re.search(r"/d/([a-zA-Z0-9_-]+)", url)
|
||||
if drive_match:
|
||||
file_id = drive_match.group(1)
|
||||
download_url = f"https://drive.google.com/uc?export=download&id={file_id}"
|
||||
dl_url = download_url
|
||||
filename = f"{file_id}"
|
||||
else:
|
||||
dl_url = url
|
||||
parsed = urlparse(url)
|
||||
filename = os.path.basename(parsed.path) or parsed.netloc
|
||||
|
||||
# Download content in a thread to avoid blocking the event loop
|
||||
get_fn = functools.partial(requests.get, dl_url, timeout=30, allow_redirects=True)
|
||||
resp = await asyncio.to_thread(get_fn)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Download failed: {resp.status_code}")
|
||||
data = resp.content
|
||||
mimetype = resp.headers.get("content-type") or mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
|
||||
# Try upload with small retry in case of transient error
|
||||
upload_resp = None
|
||||
mxc = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
upload_resp = await self.client.upload(io.BytesIO(data), content_type=mimetype, filename=filename)
|
||||
except Exception as e:
|
||||
log.warning(f"upload attempt {attempt+1} failed: {e}")
|
||||
upload_resp = e
|
||||
# Try various ways to extract content URI
|
||||
try:
|
||||
if hasattr(upload_resp, "content_uri"):
|
||||
mxc = upload_resp.content_uri
|
||||
elif isinstance(upload_resp, dict):
|
||||
mxc = upload_resp.get("content_uri") or upload_resp.get("content_uri")
|
||||
elif hasattr(upload_resp, "response") and isinstance(upload_resp.response, dict):
|
||||
mxc = upload_resp.response.get("content_uri") or upload_resp.response.get("content_uri")
|
||||
else:
|
||||
# fallback: try to stringify and look for mxc:// pattern
|
||||
s = repr(upload_resp)
|
||||
m = re.search(r"(mxc://[A-Za-z0-9/._=-]+)", s)
|
||||
if m:
|
||||
mxc = m.group(1)
|
||||
except Exception:
|
||||
mxc = None
|
||||
|
||||
if mxc:
|
||||
break
|
||||
# small backoff
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# If still no mxc, log debug info and raise
|
||||
if not mxc:
|
||||
log.warning("upload_resp (for debugging): %r", upload_resp)
|
||||
raise RuntimeError("No mxc returned from upload")
|
||||
|
||||
info = {"mimetype": mimetype, "size": len(data)}
|
||||
content = {
|
||||
"msgtype": "m.image",
|
||||
"body": filename,
|
||||
"url": mxc,
|
||||
"info": info,
|
||||
}
|
||||
await self.client.room_send(room_id, message_type="m.room.message", content=content)
|
||||
return
|
||||
except Exception as e:
|
||||
log.warning(f"Upload/send media failed for {url}: {e}; falling back to external link")
|
||||
# Fallback: send external link as a message so clients can still access it
|
||||
try:
|
||||
content = {"msgtype": "m.text", "body": url}
|
||||
await self.client.room_send(room_id, message_type="m.room.message", content=content)
|
||||
except Exception as e2:
|
||||
log.error(f"Failed fallback send for {url}: {e2}")
|
||||
|
||||
async def run(self):
|
||||
await self.login()
|
||||
await self.client.sync_forever(timeout=30000)
|
||||
|
||||
if __name__ == "__main__":
|
||||
bot = MatrixBot()
|
||||
asyncio.run(bot.run())
|
||||
BIN
instance/treasure.db
Normal file
BIN
instance/treasure.db
Normal file
Binary file not shown.
53
models.py
Normal file
53
models.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from datetime import datetime
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class Player(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
matrix_id = db.Column(db.String(255), unique=True, nullable=False)
|
||||
current_step = db.Column(db.Integer, default=1)
|
||||
# Track per-player flow state and hint indices
|
||||
stage = db.Column(db.String(50), nullable=True) # e.g. 'intro_needed','awaiting_code','awaiting_answer'
|
||||
location_hint_index = db.Column(db.Integer, default=0)
|
||||
question_hint_index = db.Column(db.Integer, default=0)
|
||||
last_sent_step = db.Column(db.Integer, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
# Step model matching the CSV columns expected by the application:
|
||||
# step, pre_text, location, location_enigma, location_hint, code,
|
||||
# question, question_hint, answer, comments, success_text
|
||||
class Step(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
step = db.Column(db.Integer, unique=True, nullable=False)
|
||||
pre_text = db.Column(db.Text) # pre_texttexte to show at start of each steps
|
||||
location = db.Column(db.String(255)) # location (for game master)
|
||||
location_enigma = db.Column(db.Text)
|
||||
location_hint = db.Column(db.String(255)) # location_hint
|
||||
code = db.Column(db.String(50), nullable=False)
|
||||
question = db.Column(db.Text) # question
|
||||
question_hint = db.Column(db.Text) # question_hint
|
||||
answer = db.Column(db.Text) # answer
|
||||
comments = db.Column(db.Text) # comments
|
||||
success_text = db.Column(db.Text) # success_text
|
||||
|
||||
# Display/media fields
|
||||
|
||||
image_path = db.Column(db.String(255))
|
||||
audio_path = db.Column(db.String(255))
|
||||
|
||||
|
||||
class MessageLog(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('player.id'))
|
||||
sender = db.Column(db.String(50)) # mag, lia, admin
|
||||
content = db.Column(db.Text)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class GameSession(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
room_id = db.Column(db.String(255), unique=True, nullable=False)
|
||||
player_matrix_id = db.Column(db.String(255), nullable=False) # seul ce matrix_id peut discuter avec l'IA
|
||||
started_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
58
requirements.txt
Normal file
58
requirements.txt
Normal file
@@ -0,0 +1,58 @@
|
||||
aiofiles==24.1.0
|
||||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.12.15
|
||||
aiohttp_socks==0.10.1
|
||||
aiosignal==1.4.0
|
||||
annotated-types==0.7.0
|
||||
anyio==4.10.0
|
||||
attrs==25.3.0
|
||||
blinker==1.9.0
|
||||
certifi==2025.8.3
|
||||
charset-normalizer==3.4.3
|
||||
click==8.2.1
|
||||
distro==1.9.0
|
||||
Flask==3.1.1
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
frozenlist==1.7.0
|
||||
greenlet==3.2.4
|
||||
h11==0.16.0
|
||||
h2==4.2.0
|
||||
hpack==4.1.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
hyperframe==6.1.0
|
||||
idna==3.10
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
jiter==0.10.0
|
||||
jsonschema==4.25.0
|
||||
jsonschema-specifications==2025.4.1
|
||||
markdown2==2.5.4
|
||||
MarkupSafe==3.0.2
|
||||
matrix-nio==0.25.2
|
||||
multidict==6.6.4
|
||||
numpy==2.3.2
|
||||
openai==1.99.9
|
||||
pandas==2.3.1
|
||||
propcache==0.3.2
|
||||
pycryptodome==3.23.0
|
||||
pydantic==2.11.7
|
||||
pydantic_core==2.33.2
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.1.1
|
||||
python-socks==2.7.2
|
||||
pytz==2025.2
|
||||
referencing==0.36.2
|
||||
requests==2.32.4
|
||||
rpds-py==0.27.0
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.43
|
||||
tqdm==4.67.1
|
||||
typing-inspection==0.4.1
|
||||
typing_extensions==4.14.1
|
||||
tzdata==2025.2
|
||||
unpaddedbase64==2.1.0
|
||||
urllib3==2.5.0
|
||||
Werkzeug==3.1.3
|
||||
yarl==1.20.1
|
||||
Reference in New Issue
Block a user