From 779d68dba969d8d7dc41e9972fd9d1c704a67c46 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 27 Aug 2025 16:54:36 +0200 Subject: [PATCH] first commit --- .env | 21 + __init.py__ | 0 __pycache__/app.cpython-312.pyc | Bin 0 -> 40802 bytes __pycache__/config.cpython-312.pyc | Bin 0 -> 142 bytes __pycache__/models.cpython-312.pyc | Bin 0 -> 4300 bytes app.py | 801 +++++++++++++++++++++++++++++ bot.py | 365 +++++++++++++ instance/treasure.db | Bin 0 -> 61440 bytes models.py | 53 ++ requirements.txt | 58 +++ 10 files changed, 1298 insertions(+) create mode 100644 .env create mode 100644 __init.py__ create mode 100644 __pycache__/app.cpython-312.pyc create mode 100644 __pycache__/config.cpython-312.pyc create mode 100644 __pycache__/models.cpython-312.pyc create mode 100644 app.py create mode 100644 bot.py create mode 100644 instance/treasure.db create mode 100644 models.py create mode 100644 requirements.txt 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 0000000000000000000000000000000000000000..692e70306cd61bece565ce6ba762b42c5ae2a24a GIT binary patch literal 40802 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 literal 0 HcmV?d00001 diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4a90c50b135ae78e1732625c839e56b68db71d30 GIT binary patch literal 142 zcmX@j%ge<81l4cmW`O9&AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdrK_KkSdysklAm0f zo0?ZrtRI|Nl3J`^Qk0rlTw0VGpHZ4uqMw|fmzJ5XS5Wzj!zMRBr8Fniu80+=n-PeM PL5z>gjEsy$%s>_ZutOoJ literal 0 HcmV?d00001 diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73df7e93585b493e5f1aa40308a2f4de7365a5d7 GIT binary patch literal 4300 zcmcgvO-vg{6yEi}jeoKEag@^hlmsVAXrWT$q)kafNlgfC0#$<5YP)zRW{KA(vulAP zRdLiD;DoIljK#sA9xwu_^wb=yUVDkU7qn8ORy}cx6ne>}eY1<#;MfVJN=M@Do7wl~ zy&b;y&GWA=mxF?lxn7!x+9~Q!GN}ynj`I9BPzn@Badd(b=qODS-H2c* zc1)v219gSsj1MW!gp8B6%2w6qno-lEX4TRRmX_C8T2xCbSlV7=X;m%lVCi^`r7hDG za&D1SLbT!-xju9$5t~3l24*&nm5{^>C|1pyqV&UnY~U~u6{R^UYJf0sY_$Vc)nnbe z&e7|JHSi`QNZM|bRs-gID3=fCo#cV+8p=y`beS!+7-Odff8b=Md82!IsJ7YR0l@aQV7G)-Y% zU~z`IgECuxgYUqqL9wKh{M|HCY{_&Y!Hy@;7WqGdwUtA+*>pnM>QU#|2@g!LF&t`E zYz&iR1;j9lgJFaemrfAf$uM`*Y+~n)fg4vWms5$fkW{RZq=e!KD+ZoZEF%);lX1l^ zu%OH^u;h%TF-A#=5hXOGm_><=BgLOc#aM|?C7B5xUhzo|%_x3IR;*ogCs+}D^$l$O zP#~jFAlL*6!#7H?WNKP*#4v&v9K%Yh)N^=4y*SeiwZwM!r($UV+{Nw@UP5BG1cqW7 zBW5CKVrc2|;@OSB&*q$6_6}qtoBozUI3HfPQHmC$8@^mv_V;Foes{X(BXg15c!AIJ zONK{6nVXHs&fe^&n}-goyL_@TxIFlnUI@vD&Ob4=HCk?ID~#pG7I5ia@!m#%ZcJ_o zXNNa^{z5PxT<9rXC|=mG{p9)4lMBkez6XQ&oytemr;|S5!#7GN^@xDxjO!+yM63-9 z!wb3Bj0zD%8xu%NjSEaUbH%Unj5el#S4C}H{`-sCm;~ziTPoSYc~z;?eW3I59LURi zATQs6yqXW>E4Im= zquI}@E5TxLsduqM_H@B&Yr968E7oPJ+}f4>d^6Bmh~y&+<0ZbxmyL@_IdCR>ZBvh< z66B+=%U_a6y>%n$JH(4|IdB5JU7q=|xv?BBOy{R%*W1~FYBqX)I^qemtP*l{wL-?^f}@Etv}43@4m_!woP{W5BZHnmI9X|T>Y|s3;xpUhTsM}p;x{uB^ b7uxgf+Z55ay(X~B9V>L@yZ)kxuGYstQPdVt literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..96a47cff520b79a9e666082bfe67ee9d4ac327f2 GIT binary patch literal 61440 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 AVE_OC literal 0 HcmV?d00001 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