kuji@portfolio:~/projets$ cat bot_discord.md

Bot Discord

Bot qui compare l'emploi du temps de Télécom Nancy à intervalles réguliers et envoie une notification Discord au moindre changement.

Python API

Pourquoi ce projet

Il n'y avait pas d'application pratique pour regarder l'emploi du temps à Télécom Nancy, seulement un site. Je n'avais aucun moyen de savoir quand il y avait un changement dans mon emploi du temps. J'ai donc cherché le moyen le plus simple pour me faire notifier de tout changement : Discord.

Principe

Le script ne se connecte à aucune API Télécom Nancy à proprement parler : il télécharge simplement le fichier .ics public que l'école publie pour l'import dans un calendrier, et le compare à sa propre sauvegarde de la veille.

  1. Téléchargement et parsing. requests.get récupère le .ics, et la bibliothèque ics le transforme en une liste d'évènements exploitables en Python.
  2. Un identifiant par cours. Chaque évènement devient une chaîne "Nom - date de HH:mm à HH:mm", rassemblée dans un set. Cette chaîne sert à la fois d'affichage et de clé de comparaison : deux cours sont « identiques » seulement si leur nom, leur date et leurs deux horaires correspondent exactement.
  3. Comparaison à la mémoire. L'état de la veille est relu depuis memoire.json. La différence entre deux set (-) donne directement les cours apparus et les cours disparus, sans avoir à comparer les évènements un par un.
  4. Notification et sauvegarde. S'il y a une différence, un embed Discord est envoyé via le webhook, puis le nouvel état écrase memoire.json pour la comparaison suivante.

Une conséquence de cette façon de comparer, que j'ai vérifiée en le testant avec un faux emploi du temps : un cours simplement déplacé n'est pas détecté comme « modifié », il apparaît comme deux évènements distincts, puisque son horaire fait partie de sa clé. Il ressort à la fois dans les annulés (son ancien horaire) et dans les ajoutés (son nouvel horaire), ce que les deux champs de l'embed, « Ajouté ou Déplacé » et « Annulé ou Ancien horaire », annoncent déjà.

Le détail qui fait tenir le tri

tri_chrono reconstruit, à partir de la chaîne d'affichage, une chaîne de la forme AAAAMMJJHH:mm : une date écrite dans cet ordre se trie alphabétiquement exactement comme elle se trierait chronologiquement. Sans ça, les cours s'afficheraient dans l'ordre où set les range en interne, c'est-à-dire arbitraire.

Testé sur un changement d'année : un 01/09/2026, un 20/12/2026 et un 05/01/2027 ressortent bien dans cet ordre malgré le changement d'année.

Sécurité : le webhook ne doit jamais apparaître dans le code

Un webhook Discord est un secret : quiconque possède son URL peut poster dans le salon visé, sans avoir besoin d'un compte ni d'une invitation sur le serveur. L'écrire en dur dans le script, comme url_webhook = "https://discord.com/api/webhooks/…", revient à le rendre public dès que le code est mis sur GitHub, même sur un dépôt qu'on croit anodin.

La pratique standard est de le sortir du code, par exemple via une variable d'environnement (os.environ), et de ne jamais committer le fichier qui la définit (un .env ajouté au .gitignore). C'est ce que fait la version ci-dessous.

Limites

Code source

auto_cal_nas.py
import requests
import json
import os
from ics import Calendar

url_ics = "https://edt.telecomnancy.univ-lorraine.fr/static/fise_1a_fisea.ics"
url_webhook = os.environ["DISCORD_WEBHOOK_URL"]  # jamais en dur : voir la section Sécurité

# --- FONCTION DE TRI (AVEC HEURE DE FIN) ---
def tri_chrono(cours_str):
    # Extrait "12/09/2026 de 08:00 à 10:00"
    date_bloc = cours_str.rsplit(" - ", 1)[-1]

    # Sépare la date et le bloc des heures
    date_part, heures_part = date_bloc.split(" de ")
    # Extrait juste l'heure de début (avant le " à ")
    h_debut = heures_part.split(" à ")[0]

    jour, mois, annee = date_part.split("/")
    return f"{annee}{mois}{jour}{h_debut}"

# 1. Récupération et formatage
response = requests.get(url_ics)
# LE CORRECTIF DES ACCENTS EST ICI : on force l'UTF-8 au téléchargement
response.encoding = 'utf-8'
cal = Calendar(response.text)

cours_act = set()

for event in cal.events:
    debut = event.begin.to('Europe/Paris')
    fin = event.end.to('Europe/Paris')

    date_str = debut.format('DD/MM/YYYY')
    h_debut = debut.format('HH:mm')
    h_fin = fin.format('HH:mm')

    # Nouveau format : "Cours - DD/MM/YYYY de HH:mm à HH:mm"
    cours_act.add(f"{event.name} - {date_str} de {h_debut} à {h_fin}")

old_cours = set()
premier_lancement = True

# 2. Lecture de la mémoire
if os.path.exists("memoire.json"):
    premier_lancement = False
    with open("memoire.json", "r", encoding="utf-8") as f:
        old_cours = set(json.load(f))

cours_ajoutes = cours_act - old_cours
cours_annules = old_cours - cours_act

# 3. Envoi à Discord
if not premier_lancement and (cours_ajoutes or cours_annules):

    embed = {
        "title": "🚨 Mise à jour de l'emploi du temps !",
        "color": 16753920,
        "fields": []
    }

    if cours_ajoutes:
        cours_ajoutes_tries = sorted(cours_ajoutes, key=tri_chrono)
        texte_ajouts = "\n".join(cours_ajoutes_tries)[:1000]
        embed["fields"].append({
            "name": "✅ Ajouté ou Déplacé",
            "value": texte_ajouts,
            "inline": False
        })

    if cours_annules:
        cours_annules_tries = sorted(cours_annules, key=tri_chrono)
        texte_annules = "\n".join(cours_annules_tries)[:1000]
        embed["fields"].append({
            "name": "❌ Annulé ou Ancien horaire",
            "value": texte_annules,
            "inline": False
        })

    requests.post(url_webhook, json={"embeds": [embed]})

# 4. Sauvegarde
# Le tri fonctionnera maintenant parfaitement sur toute l'année scolaire
cours_sauvegarde_tries = sorted(cours_act, key=tri_chrono)
with open("memoire.json", "w", encoding="utf-8") as f:
    json.dump(cours_sauvegarde_tries, f, ensure_ascii=False, indent=4)