| """ |
| bot.py |
| Bot Telegram pour "Hilal Ai", un LLM Transformer Décodeur entraîné from |
| scratch au niveau caractère par LabZero / Suayki. |
| |
| Prérequis : |
| pip install python-telegram-bot==21.* torch |
| |
| Fichiers attendus dans le même dossier : |
| - model.pt (poids entraînés, obligatoire) |
| - vocab.json (vocabulaire char-level, fortement recommandé) |
| - model.py (architecture, fourni séparément) |
| |
| Variables d'environnement (NE JAMAIS mettre le token en clair dans le code) : |
| TELEGRAM_BOT_TOKEN -> token du bot obtenu via @BotFather |
| MODEL_PATH -> chemin vers model.pt (défaut: "model.pt") |
| VOCAB_PATH -> chemin vers vocab.json (défaut: "vocab.json") |
| DEVICE -> "cpu" ou "cuda" (défaut: auto-détection) |
| MAX_NEW_TOKENS -> nombre max de caractères générés (défaut: 200) |
| TEMPERATURE -> température de sampling (défaut: 0.7) |
| |
| Lancement : |
| export TELEGRAM_BOT_TOKEN="xxxx:yyyy" |
| python bot.py |
| """ |
|
|
| import asyncio |
| import datetime |
| import logging |
| import os |
| import re |
| import sys |
|
|
| import torch |
| from telegram import Update |
| from telegram.constants import ChatAction |
| from telegram.ext import ( |
| Application, |
| CommandHandler, |
| ContextTypes, |
| MessageHandler, |
| filters, |
| ) |
|
|
| from model import BLOCK_SIZE, load_model |
|
|
| |
| |
| |
| logging.basicConfig( |
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", |
| level=logging.INFO, |
| ) |
| logger = logging.getLogger("hilal_ai.bot") |
|
|
| TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN") |
| if not TELEGRAM_BOT_TOKEN: |
| logger.critical( |
| "TELEGRAM_BOT_TOKEN n'est pas défini. " |
| "Sur HF Spaces : Settings -> Repository secrets -> ajoute TELEGRAM_BOT_TOKEN. " |
| "En local : export TELEGRAM_BOT_TOKEN='ton_token' avant de démarrer." |
| ) |
| raise RuntimeError("TELEGRAM_BOT_TOKEN manquant") |
|
|
| MODEL_PATH = os.environ.get("MODEL_PATH", "model.pt") |
| VOCAB_PATH = os.environ.get("VOCAB_PATH", "vocab.json") |
| DEVICE = os.environ.get("DEVICE") or ("cuda" if torch.cuda.is_available() else "cpu") |
| MAX_NEW_TOKENS = int(os.environ.get("MAX_NEW_TOKENS", "200")) |
| TEMPERATURE = float(os.environ.get("TEMPERATURE", "0.7")) |
|
|
| |
| |
| |
| |
| |
| |
| TELEGRAM_API_BASE_URL = os.environ.get("TELEGRAM_API_BASE_URL", "").strip() |
|
|
| |
| BOT_NAME = "Hilal Ai" |
| BOT_SKILLS = "Discuter, Résumer, Traduire, Rédiger" |
| DEV_NAME = "LabZero" |
| DEV_LINK = "https://t.me" |
| DEV_CHAT_ID = "-1004489211751" |
| PROGRAMMER_NAME = "Suayki" |
| PROGRAMMER_LINK = "https://t.me" |
| PROGRAMMER_USER_ID = "6074566712" |
|
|
| IDENTITY_TEXT = ( |
| f"🤖 *{BOT_NAME}*\n\n" |
| f"Compétences : {BOT_SKILLS}.\n\n" |
| f"Développeur : [{DEV_NAME}]({DEV_LINK})\n" |
| f"Programmeur : [{PROGRAMMER_NAME}]({PROGRAMMER_LINK})" |
| ) |
|
|
| |
| DATETIME_TRIGGERS = re.compile( |
| r"\b(quelle heure|quelle date|date du jour|heure actuelle|" |
| r"on est quel jour|quel jour sommes[- ]nous|date et heure)\b", |
| re.IGNORECASE, |
| ) |
|
|
| |
| IDENTITY_TRIGGERS = re.compile( |
| r"\b(qui es[- ]tu|qui t'a (créé|developpé|développé|fait)|" |
| r"ton (créateur|développeur|programmeur)|c'est qui ton (créateur|développeur))\b", |
| re.IGNORECASE, |
| ) |
|
|
| |
| |
| |
| logger.info("Chargement du modèle depuis %s sur %s ...", MODEL_PATH, DEVICE) |
| try: |
| model, tokenizer = load_model(MODEL_PATH, VOCAB_PATH, device=DEVICE) |
| logger.info("Modèle chargé avec succès. vocab_size=%d", tokenizer.vocab_size) |
| except FileNotFoundError: |
| logger.critical( |
| "Fichier de poids introuvable : %s. Place model.pt à la racine du repo Space " |
| "ou définis la variable MODEL_PATH dans les Secrets/Variables du Space.", |
| MODEL_PATH, |
| ) |
| |
| |
| |
| |
| |
| raise |
| except Exception: |
| logger.critical("Échec du chargement du modèle.", exc_info=True) |
| raise |
| logger.info("Bot prêt à démarrer.") |
|
|
| |
| |
| _generation_lock = asyncio.Lock() |
|
|
|
|
| |
| |
| |
| def _build_prompt(user_text: str) -> str: |
| """ |
| Construit le prompt final envoyé au modèle. Injecte la date/heure serveur |
| si l'utilisateur la demande explicitement. |
| """ |
| if DATETIME_TRIGGERS.search(user_text): |
| now = datetime.datetime.now().strftime("%d/%m/%Y %H:%M") |
| user_text = f"[Date et heure actuelles : {now}]\n{user_text}" |
| return user_text |
|
|
|
|
| def _safe_generate(prompt: str, max_new_tokens: int, temperature: float) -> str: |
| """ |
| Génère une réponse de façon synchrone et sécurisée : |
| - tronque le prompt pour ne garder que les BLOCK_SIZE derniers caractères |
| (le modèle ne peut de toute façon pas voir plus loin que son contexte) |
| - encode/décode avec le tokenizer char-level |
| - retourne uniquement le texte nouvellement généré (sans répéter le prompt) |
| """ |
| prompt = prompt[-BLOCK_SIZE:] |
| encoded = tokenizer.encode(prompt) |
| if not encoded: |
| encoded = tokenizer.encode(" ") |
|
|
| idx = torch.tensor([encoded], dtype=torch.long, device=DEVICE) |
|
|
| with torch.no_grad(): |
| out = model.generate( |
| idx, |
| max_new_tokens=max_new_tokens, |
| temperature=temperature, |
| ) |
|
|
| generated_ids = out[0].tolist()[len(encoded):] |
| text = tokenizer.decode(generated_ids) |
| return text.strip() |
|
|
|
|
| async def generate_reply(user_text: str) -> str: |
| """Wrapper async : exécute la génération (bloquante, CPU/GPU-bound) dans |
| un thread séparé pour ne pas bloquer la boucle d'événements du bot, et |
| sérialise les accès au modèle via un verrou.""" |
| prompt = _build_prompt(user_text) |
| async with _generation_lock: |
| try: |
| reply = await asyncio.to_thread( |
| _safe_generate, prompt, MAX_NEW_TOKENS, TEMPERATURE |
| ) |
| except Exception: |
| logger.exception("Erreur pendant la génération de texte.") |
| return "Désolé, une erreur interne est survenue pendant la génération. Réessaie." |
|
|
| if not reply: |
| reply = "Je n'ai pas pu générer de réponse claire, peux-tu reformuler ?" |
| return reply |
|
|
|
|
| |
| |
| |
| async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: |
| await update.message.reply_text( |
| f"Salut ! Je suis {BOT_NAME} 🤖\n" |
| f"Je peux discuter, résumer, traduire et rédiger.\n" |
| f"Envoie-moi simplement un message, ou tape /about pour en savoir plus sur moi." |
| ) |
|
|
|
|
| async def cmd_about(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: |
| await update.message.reply_text( |
| IDENTITY_TEXT, |
| parse_mode="Markdown", |
| disable_web_page_preview=True, |
| ) |
|
|
|
|
| async def cmd_reset(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: |
| """Optionnel : si tu ajoutes un historique de conversation par utilisateur, |
| cette commande permet de le réinitialiser. Le modèle char-level de base |
| n'a pas de mémoire de conversation par défaut (pas de chat template).""" |
| context.user_data.clear() |
| await update.message.reply_text("Contexte de conversation réinitialisé. ✅") |
|
|
|
|
| async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: |
| user_text = update.message.text or "" |
| user_text = user_text.strip() |
|
|
| if not user_text: |
| return |
|
|
| if IDENTITY_TRIGGERS.search(user_text): |
| await update.message.reply_text( |
| IDENTITY_TEXT, |
| parse_mode="Markdown", |
| disable_web_page_preview=True, |
| ) |
| return |
|
|
| await context.bot.send_chat_action( |
| chat_id=update.effective_chat.id, action=ChatAction.TYPING |
| ) |
|
|
| reply = await generate_reply(user_text) |
| await update.message.reply_text(reply) |
|
|
|
|
| async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: |
| logger.error("Exception non gérée :", exc_info=context.error) |
| if isinstance(update, Update) and update.effective_message: |
| try: |
| await update.effective_message.reply_text( |
| "Une erreur est survenue, désolé. Réessaie dans un instant." |
| ) |
| except Exception: |
| pass |
|
|
|
|
| |
| |
| |
| async def _run_bot_async() -> None: |
| """Pilote manuellement le cycle de vie de l'Application Telegram. |
| |
| On évite Application.run_polling() ici : cette méthode est conçue pour |
| être appelée depuis le thread principal (elle gère ses propres signaux |
| et sa propre boucle asyncio en interne via asyncio.run()). Dans un |
| thread secondaire comme celui utilisé sur HF Spaces, cela provoque |
| l'erreur "There is no current event loop in thread ...". On reproduit |
| donc manuellement ce que fait run_polling() en interne, mais à |
| l'intérieur d'une coroutine que NOUS lançons via asyncio.run(), ce qui |
| garantit une boucle d'événements propre et valide pour ce thread. |
| """ |
| builder = Application.builder().token(TELEGRAM_BOT_TOKEN) |
|
|
| |
| |
| |
| |
| |
| builder = builder.connect_timeout(20.0) |
| builder = builder.read_timeout(20.0) |
| builder = builder.write_timeout(20.0) |
| builder = builder.pool_timeout(20.0) |
| |
| |
| |
| |
| |
| builder = builder.get_updates_connect_timeout(20.0) |
| builder = builder.get_updates_read_timeout(15.0) |
|
|
| if TELEGRAM_API_BASE_URL: |
| |
| |
| |
| base = TELEGRAM_API_BASE_URL.strip().rstrip("/") |
| |
| |
| |
| if not base.startswith("http://") and not base.startswith("https://"): |
| logger.warning( |
| "TELEGRAM_API_BASE_URL ne contient pas de schéma (http/https) : " |
| "'%s'. Ajout automatique de 'https://'.", base |
| ) |
| base = "https://" + base |
| |
| |
| builder = builder.base_url(f"{base}/bot") |
| logger.info("Utilisation du proxy Telegram : %s", base) |
|
|
| app = builder.build() |
|
|
| app.add_handler(CommandHandler("start", cmd_start)) |
| app.add_handler(CommandHandler("about", cmd_about)) |
| app.add_handler(CommandHandler("reset", cmd_reset)) |
| app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) |
| app.add_error_handler(error_handler) |
|
|
| logger.info("Hilal Ai démarre (polling)...") |
|
|
| |
| |
| max_retries = 5 |
| for attempt in range(1, max_retries + 1): |
| try: |
| await app.initialize() |
| logger.info("Application initialisée (tentative %d/%d).", attempt, max_retries) |
| break |
| except Exception: |
| logger.warning( |
| "Échec de app.initialize() (tentative %d/%d), nouvelle tentative dans 3s...", |
| attempt, max_retries, exc_info=True, |
| ) |
| if attempt == max_retries: |
| raise |
| await asyncio.sleep(3) |
|
|
| await app.start() |
| logger.info("Application démarrée (app.start() terminé).") |
| await app.updater.start_polling( |
| allowed_updates=Update.ALL_TYPES, |
| drop_pending_updates=True, |
| |
| |
| |
| |
| |
| |
| timeout=3, |
| poll_interval=1.0, |
| ) |
| logger.info( |
| "start_polling() terminé. updater.running=%s", |
| app.updater.running, |
| ) |
|
|
| |
| |
| |
| stop_event = asyncio.Event() |
| try: |
| |
| |
| while True: |
| await asyncio.sleep(60) |
| logger.info( |
| "Heartbeat : bot toujours actif. updater.running=%s", |
| app.updater.running, |
| ) |
| finally: |
| await app.updater.stop() |
| await app.stop() |
| await app.shutdown() |
|
|
|
|
| def main() -> None: |
| |
| |
| |
| |
| |
| asyncio.run(_run_bot_async()) |
|
|
|
|
| def run_bot_blocking() -> None: |
| """Point d'entrée appelé depuis un thread secondaire par app.py |
| (le serveur web de ping reste dans le thread principal sur HF Spaces).""" |
| main() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
| |