commit 779d68dba969d8d7dc41e9972fd9d1c704a67c46 Author: Sam Date: Wed Aug 27 16:54:36 2025 +0200 first commit diff --git a/.env b/.env new file mode 100644 index 0000000..9f79ab4 --- /dev/null +++ b/.env @@ -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" diff --git a/__init.py__ b/__init.py__ new file mode 100644 index 0000000..e69de29 diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..692e703 Binary files /dev/null and b/__pycache__/app.cpython-312.pyc differ diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..4a90c50 Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..73df7e9 Binary files /dev/null and b/__pycache__/models.cpython-312.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..e812d9c --- /dev/null +++ b/app.py @@ -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": , "matrix_id": "@user:server" } + Returns: { status: ok, reply: , 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": , "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") diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..ad375ce --- /dev/null +++ b/bot.py @@ -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", "
")) + + 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()) diff --git a/instance/treasure.db b/instance/treasure.db new file mode 100644 index 0000000..96a47cf Binary files /dev/null and b/instance/treasure.db differ diff --git a/models.py b/models.py new file mode 100644 index 0000000..0fd4c6e --- /dev/null +++ b/models.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c9eac55 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file