From 8604dc78b137990f07c96738c552db93de62b304 Mon Sep 17 00:00:00 2001 From: DevOps Engineer Date: Wed, 29 Apr 2026 16:42:15 +0200 Subject: [PATCH] feat(HRT-79): alertes Telegram configurables Premium/Pro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - telegram_alerts.py: service envoi alertes via Bot API (send_pre_race_alerts, build_race_alert, send_telegram_message) — gestion gracieuse TELEGRAM_BOT_TOKEN absent - auth_db.py: migrate_telegram_columns() idempotente (ALTER TABLE + try/except OperationalError) colonnes: telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only - api_v1/routes/user.py: blueprint user_bp GET/POST /api/v1/user/telegram-config protégé @jwt_required_middleware + @plan_required('premium','pro') - api_v1/__init__.py: import + register user_bp - turf_scheduler.py: run_telegram_alerts() + schedule_dynamic_telegram_alerts() planifiées 30min avant course (même pattern que schedule_dynamic_scoring) avec try/except Exception + fallback logger Co-Authored-By: Paperclip --- api_v1/__init__.py | 4 + api_v1/routes/user.py | 216 ++++++++++++++++++++++++++++++++ auth_db.py | 31 +++++ telegram_alerts.py | 284 ++++++++++++++++++++++++++++++++++++++++++ turf_scheduler.py | 62 +++++++++ 5 files changed, 597 insertions(+) create mode 100644 api_v1/routes/user.py create mode 100644 telegram_alerts.py diff --git a/api_v1/__init__.py b/api_v1/__init__.py index 1877f1a..f5fd791 100644 --- a/api_v1/__init__.py +++ b/api_v1/__init__.py @@ -3,6 +3,7 @@ API v1 Blueprint package — Turf SaaS Sprint 3-4: HRT-29 — Refacto API /v1/ Sprint 5-6: HRT-31 — Billing Stripe +HRT-79: Alertes Telegram configurables (user blueprint) Registers sub-blueprints: /api/v1/health — public health-check @@ -13,6 +14,7 @@ Registers sub-blueprints: /api/v1/export/ — export CSV (pro) /api/v1/metrics — métriques perf ML (premium+) /api/v1/billing/ — Stripe checkout, portal, webhook, status + /api/v1/user/ — config utilisateur, alertes Telegram (premium+) /api/v1/docs — Swagger UI (via flasgger, registered on app) """ @@ -26,6 +28,7 @@ from .routes.backtest import backtest_bp from .routes.export import export_bp from .routes.metrics import metrics_bp from .routes.billing import billing_bp +from .routes.user import user_bp # Master blueprint that aggregates all sub-routes under /api/v1 api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1") @@ -41,3 +44,4 @@ def register_api_v1(app): app.register_blueprint(export_bp) app.register_blueprint(metrics_bp) app.register_blueprint(billing_bp) + app.register_blueprint(user_bp) diff --git a/api_v1/routes/user.py b/api_v1/routes/user.py new file mode 100644 index 0000000..e5e20c7 --- /dev/null +++ b/api_v1/routes/user.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +User route for API v1 — Telegram alert configuration +HRT-79: Alertes Telegram configurables (Premium) + +GET /api/v1/user/telegram-config — Lire la config Telegram de l'utilisateur connecté +POST /api/v1/user/telegram-config — Mettre à jour la config Telegram + +Accès : Premium / Pro uniquement (@jwt_required_middleware + @plan_required) +""" + +import sqlite3 +from flask import Blueprint, jsonify, request + +from api_v1.utils import internal_error, bad_request +from auth import jwt_required_middleware, plan_required + +user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user") + +# DB_PATH est résolu via la même variable d'env que auth_db.py +import os + +_DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db") + + +def _get_db(): + conn = sqlite3.connect(_DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +# ── GET /api/v1/user/telegram-config ────────────────────────────────────────── + + +@user_bp.route("/telegram-config", methods=["GET"]) +@jwt_required_middleware +@plan_required("premium", "pro") +def get_telegram_config(): + """ + Retourne la configuration Telegram de l'utilisateur connecté. + --- + tags: + - Utilisateur + summary: Lire la config alertes Telegram (premium+) + security: + - Bearer: [] + responses: + 200: + description: Configuration Telegram courante + schema: + properties: + telegram_chat_id: + type: string + nullable: true + alert_value_bets: + type: boolean + alert_top1: + type: boolean + alert_quinte_only: + type: boolean + 401: + description: Token invalide + 403: + description: Plan insuffisant + """ + user_id = request.user_id # injecté par jwt_required_middleware + + conn = _get_db() + try: + row = conn.execute( + """ + SELECT telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only + FROM users + WHERE id = ? + """, + (user_id,), + ).fetchone() + + if not row: + return jsonify({"error": "Utilisateur introuvable"}), 404 + + return jsonify( + { + "telegram_chat_id": row["telegram_chat_id"], + "alert_value_bets": bool(row["alert_value_bets"]), + "alert_top1": bool(row["alert_top1"]), + "alert_quinte_only": bool(row["alert_quinte_only"]), + } + ), 200 + + except sqlite3.OperationalError as exc: + # Colonnes absentes : migration non appliquée + return jsonify( + { + "telegram_chat_id": None, + "alert_value_bets": True, + "alert_top1": True, + "alert_quinte_only": False, + "_warning": "Migration Telegram non appliquée", + } + ), 200 + except Exception as exc: + return internal_error(str(exc)) + finally: + conn.close() + + +# ── POST /api/v1/user/telegram-config ───────────────────────────────────────── + + +@user_bp.route("/telegram-config", methods=["POST"]) +@jwt_required_middleware +@plan_required("premium", "pro") +def update_telegram_config(): + """ + Met à jour la configuration Telegram de l'utilisateur connecté. + --- + tags: + - Utilisateur + summary: Configurer les alertes Telegram (premium+) + security: + - Bearer: [] + parameters: + - in: body + name: body + required: true + schema: + properties: + telegram_chat_id: + type: string + description: Chat ID Telegram (ou null pour désactiver) + alert_value_bets: + type: boolean + default: true + alert_top1: + type: boolean + default: true + alert_quinte_only: + type: boolean + default: false + responses: + 200: + description: Configuration mise à jour + 400: + description: Paramètres invalides + 401: + description: Token invalide + 403: + description: Plan insuffisant + """ + user_id = request.user_id # injecté par jwt_required_middleware + + data = request.get_json(silent=True) + if not data: + return bad_request("Corps JSON requis") + + # Validation et extraction des champs + telegram_chat_id = data.get("telegram_chat_id") + if telegram_chat_id is not None and not isinstance(telegram_chat_id, str): + return bad_request("telegram_chat_id doit être une chaîne ou null") + if isinstance(telegram_chat_id, str): + telegram_chat_id = telegram_chat_id.strip() or None + + alert_value_bets = data.get("alert_value_bets", True) + alert_top1 = data.get("alert_top1", True) + alert_quinte_only = data.get("alert_quinte_only", False) + + if not isinstance(alert_value_bets, bool): + return bad_request("alert_value_bets doit être un booléen") + if not isinstance(alert_top1, bool): + return bad_request("alert_top1 doit être un booléen") + if not isinstance(alert_quinte_only, bool): + return bad_request("alert_quinte_only doit être un booléen") + + conn = _get_db() + try: + conn.execute( + """ + UPDATE users + SET telegram_chat_id = ?, + alert_value_bets = ?, + alert_top1 = ?, + alert_quinte_only = ? + WHERE id = ? + """, + ( + telegram_chat_id, + int(alert_value_bets), + int(alert_top1), + int(alert_quinte_only), + user_id, + ), + ) + conn.commit() + + return jsonify( + { + "status": "ok", + "telegram_chat_id": telegram_chat_id, + "alert_value_bets": alert_value_bets, + "alert_top1": alert_top1, + "alert_quinte_only": alert_quinte_only, + } + ), 200 + + except sqlite3.OperationalError as exc: + return jsonify( + { + "error": "Migration Telegram non appliquée — contacter le support", + "detail": str(exc), + } + ), 500 + except Exception as exc: + return internal_error(str(exc)) + finally: + conn.close() diff --git a/auth_db.py b/auth_db.py index c3934e1..05eb1df 100644 --- a/auth_db.py +++ b/auth_db.py @@ -2,6 +2,7 @@ """ Auth DB — users and subscriptions schema for turf_saas.db Sprint 2-3: Auth JWT + Multi-tenant (HRT-28) +HRT-79: migration Telegram columns """ import sqlite3 @@ -63,6 +64,36 @@ def init_auth_tables(): conn.close() print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.") + # Apply Telegram columns migration (idempotent) + migrate_telegram_columns() + + +def migrate_telegram_columns(): + """ + Migration idempotente : ajoute les colonnes Telegram à la table users. + Utilise ALTER TABLE ... ADD COLUMN avec try/except OperationalError + pour être safe si les colonnes existent déjà (SQLite ne supporte pas IF NOT EXISTS). + HRT-79 + """ + conn = get_db() + c = conn.cursor() + columns = [ + ("telegram_chat_id", "TEXT DEFAULT NULL"), + ("alert_value_bets", "INTEGER DEFAULT 1"), + ("alert_top1", "INTEGER DEFAULT 1"), + ("alert_quinte_only", "INTEGER DEFAULT 0"), + ] + for col, definition in columns: + try: + c.execute(f"ALTER TABLE users ADD COLUMN {col} {definition}") + print(f"[auth_db] Colonne '{col}' ajoutée.") + except sqlite3.OperationalError: + # Column already exists — safe to ignore + pass + conn.commit() + conn.close() + print("[auth_db] Migration Telegram columns OK.") + if __name__ == "__main__": init_auth_tables() diff --git a/telegram_alerts.py b/telegram_alerts.py new file mode 100644 index 0000000..d5fc0f0 --- /dev/null +++ b/telegram_alerts.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +""" +Telegram Alerts — Service d'alertes pré-course pour les utilisateurs Premium/Pro +HRT-79: Alertes Telegram configurables (Premium) + +Fonctionnement : + - 30 minutes avant chaque course détectée, envoie un message Telegram + aux utilisateurs Premium/Pro ayant configuré leur chat_id. + - Les préférences individuelles (value_bets, top1, quinte_only) sont respectées. + - Requiert la variable d'environnement TELEGRAM_BOT_TOKEN. +""" + +import os +import logging +import sqlite3 +from datetime import datetime +from typing import Optional + +import requests + +logger = logging.getLogger(__name__) + +DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db") +BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") + +TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/sendMessage" + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def send_telegram_message(chat_id: str, text: str) -> bool: + """ + Envoie un message Telegram à un chat_id donné. + + Returns True si succès, False sinon. + Ne lève pas d'exception pour ne pas crasher le scheduler. + """ + if not BOT_TOKEN: + logger.warning("[TELEGRAM] TELEGRAM_BOT_TOKEN non configuré — envoi ignoré") + return False + + url = TELEGRAM_API_BASE.format(token=BOT_TOKEN) + payload = { + "chat_id": chat_id, + "text": text, + "parse_mode": "Markdown", + "disable_web_page_preview": True, + } + try: + resp = requests.post(url, json=payload, timeout=10) + if resp.status_code == 200: + return True + logger.warning( + "[TELEGRAM] Echec envoi chat_id=%s status=%d body=%s", + chat_id, + resp.status_code, + resp.text[:200], + ) + return False + except requests.RequestException as exc: + logger.error("[TELEGRAM] Exception HTTP chat_id=%s: %s", chat_id, exc) + return False + + +# ── Alert builder ───────────────────────────────────────────────────────────── + + +def build_race_alert(race_data: dict, predictions: list) -> str: + """ + Construit le message Markdown de l'alerte pré-course. + + Args: + race_data: dict avec les clés 'hippo', 'num_course', 'heure', 'type_course' + predictions: liste de dicts {'num_cheval', 'nom_cheval', 'prob_top3', 'is_value_bet', 'ml_score'} + + Returns: texte Markdown formaté + """ + hippo = race_data.get("hippo", "?") + num_course = race_data.get("num_course", "?") + heure = race_data.get("heure", "?") + type_course = race_data.get("type_course", "") + + lines = [ + f"🏇 *Alerte course — {hippo} R{num_course}*", + f"⏰ Départ prévu : *{heure}*", + ] + if type_course: + lines.append(f"📋 Type : {type_course}") + lines.append("") + + top3 = [p for p in predictions if p.get("prob_top3", 0) > 0][:3] + value_bets = [p for p in predictions if p.get("is_value_bet")] + + if top3: + lines.append("📊 *Top-3 ML :*") + for i, p in enumerate(top3, 1): + nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}") + prob = p.get("prob_top3", 0) + lines.append(f" {i}. {nom} — {prob:.0%} prob top-3") + lines.append("") + + if value_bets: + lines.append("💡 *Value bets :*") + for p in value_bets[:3]: + nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}") + score = p.get("ml_score", 0) + lines.append(f" ✅ {nom} (score {score:.2f})") + lines.append("") + + lines.append("_Alerte automatique Turf SaaS — 30min avant départ_") + return "\n".join(lines) + + +# ── Main send function ──────────────────────────────────────────────────────── + + +def send_pre_race_alerts(minutes_before: int = 30) -> dict: + """ + Interroge la DB pour récupérer les courses du jour, puis envoie + des alertes Telegram aux utilisateurs Premium/Pro éligibles. + + Args: + minutes_before: non utilisé directement (la planification est gérée + par le scheduler), présent pour documentation. + + Returns: dict {'sent': int, 'skipped': int, 'errors': int} + """ + if not BOT_TOKEN: + logger.warning( + "[TELEGRAM] TELEGRAM_BOT_TOKEN absent — send_pre_race_alerts ignoré" + ) + return {"sent": 0, "skipped": 0, "errors": 0} + + stats = {"sent": 0, "skipped": 0, "errors": 0} + + try: + conn = _get_db() + today = datetime.now().strftime("%Y-%m-%d") + + # Récupère les courses du jour + try: + courses_rows = conn.execute( + """ + SELECT DISTINCT + hippo, num_course, heure_depart, type_course + FROM pmu_courses + WHERE date_programme = ? + AND heure_depart IS NOT NULL + ORDER BY heure_depart ASC + LIMIT 20 + """, + (today,), + ).fetchall() + except sqlite3.OperationalError as exc: + logger.warning("[TELEGRAM] Table pmu_courses introuvable: %s", exc) + conn.close() + return stats + + if not courses_rows: + logger.info("[TELEGRAM] Aucune course aujourd'hui — pas d'alerte") + conn.close() + return stats + + # Récupère les utilisateurs Premium/Pro avec chat_id configuré + try: + users = conn.execute( + """ + SELECT id, telegram_chat_id, + alert_value_bets, alert_top1, alert_quinte_only + FROM users + WHERE plan IN ('premium', 'pro') + AND is_active = 1 + AND telegram_chat_id IS NOT NULL + AND telegram_chat_id != '' + """, + ).fetchall() + except sqlite3.OperationalError as exc: + logger.warning( + "[TELEGRAM] Colonnes Telegram absentes (migration non appliquée?): %s", + exc, + ) + conn.close() + return stats + + if not users: + logger.info("[TELEGRAM] Aucun utilisateur avec chat_id configuré") + conn.close() + return stats + + for course_row in courses_rows: + hippo = course_row["hippo"] or "?" + num_course = course_row["num_course"] or "?" + heure_ts = course_row["heure_depart"] + type_course = course_row["type_course"] or "" + + try: + dt = datetime.fromtimestamp(heure_ts / 1000) + heure_str = dt.strftime("%H:%M") + except Exception: + heure_str = str(heure_ts) + + race_data = { + "hippo": hippo, + "num_course": num_course, + "heure": heure_str, + "type_course": type_course, + } + + # Récupère les prédictions ML pour cette course + predictions = [] + try: + pred_rows = conn.execute( + """ + SELECT num_cheval, nom_cheval, prob_top3, is_value_bet, ml_score + FROM ml_predictions_cache + WHERE date = ? + AND hippo = ? + AND num_course = ? + ORDER BY prob_top3 DESC + LIMIT 10 + """, + (today, hippo, num_course), + ).fetchall() + predictions = [dict(r) for r in pred_rows] + except sqlite3.OperationalError: + pass # table absente, on envoie quand même avec données minimales + + is_quinte = ( + "quinté" in type_course.lower() or "quinte" in type_course.lower() + ) + + for user in users: + chat_id = user["telegram_chat_id"] + alert_quinte_only = bool(user["alert_quinte_only"]) + alert_top1 = bool(user["alert_top1"]) + alert_value_bets = bool(user["alert_value_bets"]) + + # Filtre quinte_only + if alert_quinte_only and not is_quinte: + stats["skipped"] += 1 + continue + + # Construit le message selon préférences + filtered_preds = [] + if predictions: + for p in predictions: + include = False + if alert_top1 and p.get("prob_top3", 0) > 0: + include = True + if alert_value_bets and p.get("is_value_bet"): + include = True + if include: + filtered_preds.append(p) + + text = build_race_alert(race_data, filtered_preds) + ok = send_telegram_message(chat_id, text) + if ok: + stats["sent"] += 1 + else: + stats["errors"] += 1 + + conn.close() + + except Exception as exc: + logger.error("[TELEGRAM] Erreur inattendue dans send_pre_race_alerts: %s", exc) + import traceback + + traceback.print_exc() + stats["errors"] += 1 + + logger.info( + "[TELEGRAM] Alertes pré-course: %d envoyées, %d ignorées, %d erreurs", + stats["sent"], + stats["skipped"], + stats["errors"], + ) + return stats diff --git a/turf_scheduler.py b/turf_scheduler.py index 347e3e5..fce95fc 100755 --- a/turf_scheduler.py +++ b/turf_scheduler.py @@ -193,6 +193,65 @@ def schedule_dynamic_scoring(): logger.info("ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas de scoring dynamique") +def run_telegram_alerts(): + """Envoie les alertes Telegram pré-course aux utilisateurs Premium/Pro""" + logger.info("📨 [SCHEDULER] Envoi alertes Telegram pré-course...") + try: + os.chdir("/home/h3r7/turf_saas") + import telegram_alerts + + stats = telegram_alerts.send_pre_race_alerts(minutes_before=30) + logger.info( + "✅ [SCHEDULER] Alertes Telegram: %d envoyées, %d ignorées, %d erreurs", + stats.get("sent", 0), + stats.get("skipped", 0), + stats.get("errors", 0), + ) + except Exception as e: + logger.error(f"❌ [SCHEDULER] Erreur alertes Telegram: {e}") + import traceback + + traceback.print_exc() + + +def schedule_dynamic_telegram_alerts(): + """Planifie les alertes Telegram 30min avant la course (même pattern que schedule_dynamic_scoring)""" + race_time = get_todays_race_time() + + if race_time: + try: + # Convertir timestamp ms en datetime + dt = datetime.fromtimestamp(race_time / 1000) + race_hour = dt.hour + race_min = dt.minute + + logger.info( + f"📅 [SCHEDULER] Alertes Telegram — course à {race_hour:02d}:{race_min:02d}" + ) + + # Alertes 30min avant la course + pre_min = race_min - 30 + pre_hour = race_hour + if pre_min < 0: + pre_min += 60 + pre_hour -= 1 + + alert_time = f"{pre_hour:02d}:{pre_min:02d}" + schedule.every().day.at(alert_time).do(run_telegram_alerts).tag( + "telegram", "dynamic" + ) + logger.info( + f"📅 [SCHEDULER] Alertes Telegram planifiées à {alert_time} (30min avant la course)" + ) + + except Exception as e: + logger.warning(f"⚠️ Impossible de planifier les alertes Telegram: {e}") + else: + logger.info( + "ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas d'alertes Telegram dynamiques" + ) + + def schedule_dynamic_results(): """Planifie le scraping des résultats à H+1 (1h après la course)""" race_time = get_todays_race_time() @@ -245,6 +304,9 @@ def main(): # Scoring dynamique (15min avant course) schedule_dynamic_scoring() + # Alertes Telegram dynamiques (30min avant course) + schedule_dynamic_telegram_alerts() + # Résultats dynamiques (H+1) schedule_dynamic_results()