File size: 16,239 Bytes
4afadbd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b5a146
 
 
 
 
 
 
 
4afadbd
 
 
 
03d1aa7
4afadbd
 
03d1aa7
4afadbd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03d1aa7
 
 
 
 
 
 
 
 
 
 
 
6b5a146
 
061da3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b3d8871
061da3e
6b5a146
 
 
 
0a8c9e8
 
 
 
 
 
 
 
 
 
6b5a146
 
 
 
 
 
4afadbd
 
 
 
 
 
 
 
03d1aa7
061da3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03d1aa7
5f7008a
03d1aa7
4afadbd
 
b3d8871
 
 
 
 
 
 
 
4afadbd
5f7008a
 
 
 
4afadbd
03d1aa7
 
 
 
 
5f7008a
 
 
 
 
 
 
 
03d1aa7
 
 
 
 
 
 
 
 
 
 
 
 
 
4afadbd
 
 
 
 
 
 
 
6b5a146
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
"""
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

# ----------------------------------------------------------------------------
# Configuration / logging
# ----------------------------------------------------------------------------
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"))

# URL de base optionnelle vers un proxy (ex: Cloudflare Worker) relayant
# les requêtes vers api.telegram.org. Nécessaire sur HF Spaces (tier
# gratuit) qui bloque les connexions sortantes directes vers Telegram.
# Exemple de valeur : "https://telegram-proxy.toncompte.workers.dev"
# Si non définie, le bot contacte api.telegram.org directement (cas normal
# en local ou sur un VPS classique).
TELEGRAM_API_BASE_URL = os.environ.get("TELEGRAM_API_BASE_URL", "").strip()

# Identité du bot (specs fournies)
BOT_NAME = "Hilal Ai"
BOT_SKILLS = "Discuter, Résumer, Traduire, Rédiger"
DEV_NAME = "LabZero"
DEV_LINK = "https://t.me"          # à remplacer par le lien réel du canal/contact
DEV_CHAT_ID = "-1004489211751"
PROGRAMMER_NAME = "Suayki"
PROGRAMMER_LINK = "https://t.me"   # à remplacer par le lien réel
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})"
)

# Mots-clés déclenchant l'injection de la date/heure serveur dans le prompt
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,
)

# Mots-clés déclenchant la réponse d'identité statique plutôt que la génération
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,
)

# ----------------------------------------------------------------------------
# Chargement du modèle (une seule fois, au démarrage du process)
# ----------------------------------------------------------------------------
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,
    )
    # Pas de sys.exit() ici : sur HF Spaces, ce module tourne dans un thread
    # secondaire lancé par app.py. Si on tue le process, le serveur web de
    # ping meurt aussi et HF considère tout le Space comme crashé en boucle.
    # On préfère garder le serveur de ping vivant pour diagnostiquer via
    # l'endpoint "/" et les logs du Space.
    raise
except Exception:
    logger.critical("Échec du chargement du modèle.", exc_info=True)
    raise
logger.info("Bot prêt à démarrer.")

# Verrou pour éviter les accès concurrents au modèle (un seul forward pass
# CPU/GPU à la fois, simple et sûr pour un usage VPS mono-instance).
_generation_lock = asyncio.Lock()


# ----------------------------------------------------------------------------
# Génération de texte
# ----------------------------------------------------------------------------
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:]  # le contexte ne peut pas dépasser BLOCK_SIZE de toute façon
    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


# ----------------------------------------------------------------------------
# Handlers Telegram
# ----------------------------------------------------------------------------
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


# ----------------------------------------------------------------------------
# Lancement de l'application
# ----------------------------------------------------------------------------
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)

    # Timeouts généreux : le chemin réseau passe par un proxy Cloudflare
    # Worker (cold start variable) avant d'atteindre Telegram, ce qui ajoute
    # de la latence et de la variabilité par rapport à une connexion directe.
    # Les valeurs par défaut de la librairie (5s) sont trop courtes pour ce
    # chemin en cascade et provoquent des ConnectTimeout/TimedOut sporadiques.
    builder = builder.connect_timeout(20.0)
    builder = builder.read_timeout(20.0)
    builder = builder.write_timeout(20.0)
    builder = builder.pool_timeout(20.0)
    # get_updates utilise le long-polling : Telegram garde la connexion
    # ouverte jusqu'à 'timeout' secondes (par défaut côté updater) en
    # attendant un nouveau message. Il faut donc un read_timeout dédié plus
    # long que celui des autres appels, sinon chaque cycle de polling
    # timeout avant que Telegram n'ait eu la chance de répondre.
    builder = builder.get_updates_connect_timeout(20.0)
    builder = builder.get_updates_read_timeout(15.0)

    if TELEGRAM_API_BASE_URL:
        # HF Spaces (tier gratuit) bloque les connexions sortantes directes
        # vers api.telegram.org. On route donc tout le trafic via un proxy
        # (ex: Cloudflare Worker) qui relaie les requêtes à notre place.
        base = TELEGRAM_API_BASE_URL.strip().rstrip("/")
        # Tolère que le secret ait été saisi sans schéma (ex:
        # "mon-proxy.workers.dev" au lieu de "https://mon-proxy.workers.dev").
        # httpx exige un schéma explicite, sinon UnsupportedProtocol.
        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
        # Format attendu par base_url : se termine par "/bot" (sans le
        # token, qui est ajouté automatiquement par la librairie après).
        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)...")

    # Le Worker Cloudflare a une latence de cold start variable : on retente
    # quelques fois avant d'abandonner, au lieu de planter au premier échec.
    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 court (au lieu du défaut de 10s) : réduit la durée pendant
        # laquelle la connexion HTTP de long-polling reste ouverte via le
        # proxy Cloudflare Worker, ce qui limite le risque qu'une coupure
        # réseau intermédiaire interrompe la requête avant sa fin. Quitte à
        # faire des cycles plus fréquents, c'est plus fiable sur ce chemin
        # réseau en cascade (HF -> Cloudflare -> Telegram).
        timeout=3,
        poll_interval=1.0,
    )
    logger.info(
        "start_polling() terminé. updater.running=%s",
        app.updater.running,
    )

    # Empêche la coroutine de se terminer : start_polling() retourne
    # immédiatement après avoir lancé le polling en arrière-plan, il faut
    # donc bloquer ici indéfiniment pour garder le bot actif.
    stop_event = asyncio.Event()
    try:
        # Boucle de surveillance : log périodique pour confirmer que le
        # polling tourne toujours et n'a pas crashé silencieusement.
        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() crée une boucle d'événements neuve et valide pour CE
    # thread (qu'il soit principal ou secondaire), puis la ferme proprement
    # à la fin. C'est la façon recommandée de lancer du code async depuis
    # un thread secondaire en Python, contrairement à run_polling() qui
    # suppose implicitement le thread principal.
    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()