# ETM Telegram Bot — Réécriture Python ## Contexte métier Entreprise ETM-Schurig SARL — installateur RGE (PV, PAC, IRVE, HEMS) en Alsace. Bot Telegram pour les techniciens terrain — remplace les appels téléphoniques. ## Stack cible - **python-telegram-bot** v20+ (async) - **python-nextcloud** ou WebDAV direct (httpx) pour Nextcloud - **Ollama** (LLM local) pour FAQ/SAV intelligent — phase 2 - **SQLite** ou fichier JSON pour la session/état utilisateur - Déployé sur le VPS Proxmox existant (même infra que n8n) --- ## Credentials | Paramètre | Valeur | |---|---| | Bot Token | `8608752199:AAGZ9Vyop0MVm4msxUyDsUuoNvN1hKM12vc` | | Groupe Chat Chantier | `-5208574803` | | Groupe ETM-Magasinier | `-5056192608` | | Patrick ID | `8022751692` | | Nextcloud WebDAV | `https://cloud.etm-schurig.eu/remote.php/webdav` | | Nextcloud User | `Patrick.Schurig` | | Nextcloud Deck API | `https://cloud.etm-schurig.eu/index.php/apps/deck/api/v1/` | --- ## Fonctionnalités MVP ### 3 actions via boutons inline | Bouton | callback_data | Règles | |---|---|---| | 📸 Fin de Chantier | `fin` | Photo **obligatoire** + légende `NomClient / Notes` | | ⚠️ Alerte SAV | `sav` | Photo optionnelle + `NomChantier / Description` | | 📦 Matériel Manquant | `materiel` | Texte seul accepté + `NomChantier / - item1 - item2` | ### Format de saisie universel ``` NomChantier / description ou liste avec - comme séparateur ``` Exemples : - `Müller / onduleur + batterie posés` - `Müller / - cuivre 28mm - disconnecteur - soupape anti-gel` ### Chemins Nextcloud ``` Fin chantier : Chantiers/YYYYMMDD_Client/30_Photos_Chantier/photo_timestamp.jpg SAV : SAV_Urgent/YYYY-MM-DD_Client/photo.jpg Matériel : Logistique/Manquants/Client/photo_timestamp.jpg ``` ### Notifications | Action | Destinataire | Contenu | |---|---|---| | Fin chantier | Groupe Chat Chantier `-5208574803` | ✅ + ouvrier + chantier + chemin Nextcloud + "prépare la facture" | | SAV | Groupe ETM-SAV (à créer) | 🚨 + ouvrier + chantier + description + photo | | Matériel | Groupe ETM-Magasinier `-5056192608` | 📦 + ouvrier + chantier + liste formatée | ### Cartes Nextcloud Deck (phase 1b) - Tableau : **ETM Chantiers** - 3 colonnes : `Fin de Chantier` / `SAV` / `Matériel` - Créer une carte automatiquement à chaque signalement --- ## Architecture Python recommandée ``` etm_bot/ ├── main.py # Entry point, ConversationHandler ├── config.py # Tokens, IDs, URLs (chargés depuis .env) ├── handlers/ │ ├── menu.py # /start, boutons inline │ ├── fin_chantier.py # Workflow fin de chantier │ ├── sav.py # Workflow SAV │ └── materiel.py # Workflow matériel manquant ├── services/ │ ├── nextcloud.py # Upload WebDAV + Deck API │ ├── telegram.py # Helpers send_message, notify_group │ └── llm.py # Ollama FAQ/SAV — phase 2 ├── models/ │ └── session.py # État conversation par user_id (dict en mémoire ou SQLite) └── requirements.txt ``` ### États ConversationHandler ```python MENU = 0 ATTENTE_PHOTO_FIN = 1 ATTENTE_CONTENU_SAV = 2 ATTENTE_CONTENU_MATERIEL = 3 ``` --- ## Phase 2 — Wiki/FAQ SAV avec Ollama Quand un technicien signale un SAV, proposer automatiquement des solutions : ```python # services/llm.py async def chercher_solution_sav(description: str) -> str: # Appel Ollama local (mistral ou llama3) response = await ollama.chat( model="mistral", messages=[{ "role": "system", "content": "Tu es un expert en installation photovoltaïque, PAC et IRVE. " "Donne des pistes de diagnostic pour ce problème terrain." }, { "role": "user", "content": description }] ) return response["message"]["content"] ``` Exemples de questions SAV que le LLM doit pouvoir traiter : - "onduleur Fronius affiche erreur 567" - "PAC ne démarre plus après coupure EDF" - "borne IRVE ne charge plus depuis hier" --- ## Prochaine session — ordre de travail 1. `pip install python-telegram-bot httpx python-dotenv` + scaffold du projet 2. Implémenter `ConversationHandler` avec les 3 états 3. Upload WebDAV sur Nextcloud (tester avec une vraie photo) 4. Notifications groupes Telegram 5. Cartes Nextcloud Deck 6. (optionnel) Intégration Ollama FAQ SAV --- ## ⚠️ Priorité n°1 — Gestion albums multi-photos (media_group_id) ### Problème Un technicien envoie 5-8 photos d'un coup (onduleur, batteries, HEMS, tableau, câblage...). Telegram envoie chaque photo comme un webhook séparé avec le même `media_group_id`. Il faut les regrouper avant d'uploader et n'envoyer qu'une seule notification. ### Solution Python ```python # Collecter les photos d'un même album pendant 2 secondes media_groups = {} # media_group_id → liste de fichiers async def handle_photo(update, context): msg = update.message group_id = msg.media_group_id if group_id: # Ajouter à l'album en cours if group_id not in media_groups: media_groups[group_id] = { 'photos': [], 'caption': msg.caption or '', 'chat_id': msg.chat_id, 'user': msg.from_user } # Déclencher l'upload après 3 secondes (le temps de tout recevoir) context.job_queue.run_once( upload_album, 3, data={'group_id': group_id}, name=group_id ) media_groups[group_id]['photos'].append( msg.photo[-1].file_id # Meilleure qualité ) else: # Photo seule — uploader directement await upload_single_photo(msg) async def upload_album(context): group_id = context.job.data['group_id'] album = media_groups.pop(group_id, None) if not album: return # Uploader toutes les photos for i, file_id in enumerate(album['photos']): file = await context.bot.get_file(file_id) # Upload WebDAV → Nextcloud/Chantiers/YYYYMMDD_Client/30_Photos_Chantier/photo_01.jpg await upload_to_nextcloud(file, album['caption'], i + 1) # Une seule notification await notify_fin_chantier(album, len(album['photos'])) ``` ### Notification finale (exemple 6 photos) ``` ✅ Fin de chantier 👷 Patrick Schurig 🏠 Chantier : Müller 📸 6 photos archivées → Nextcloud/Chantiers/20260325_Müller/30_Photos_Chantier/ 💶 Tu peux préparer la facture finale. ``` ### Nommage des photos sur Nextcloud ``` 30_Photos_Chantier/ ├── photo_01_20260325_143201.jpg ├── photo_02_20260325_143202.jpg ├── photo_03_20260325_143203.jpg ... └── photo_06_20260325_143208.jpg ``` --- ## Étape 2 — Dropdown chantiers depuis Nextcloud ### Objectif Quand le technicien choisit une action, le bot affiche la liste des dossiers `Nextcloud/Chantiers/` comme boutons inline — plus de fautes de frappe. ### Flow ``` Technicien clique "Fin de Chantier" → Bot lit Nextcloud/Chantiers/ via WebDAV (PROPFIND) → Bot affiche liste des 10 dossiers les plus récents → Technicien clique sur le bon chantier → Bot demande la/les photo(s) → Technicien envoie photos → Upload + notification ``` ### Code Python ```python # services/nextcloud.py import httpx from xml.etree import ElementTree as ET async def get_chantiers_actifs(base_url, user, password, max_results=10): """Lire les dossiers Nextcloud/Chantiers/ via WebDAV PROPFIND""" url = f"{base_url}/remote.php/dav/files/{user}/Chantiers/" auth = (user, password) async with httpx.AsyncClient() as client: response = await client.request( "PROPFIND", url, auth=auth, headers={"Depth": "1", "Content-Type": "application/xml"}, content=b""" """ ) # Parser le XML WebDAV root = ET.fromstring(response.text) ns = {"d": "DAV:"} dossiers = [] for resp in root.findall("d:response", ns): href = resp.find("d:href", ns).text name = href.rstrip("/").split("/")[-1] # Ignorer le dossier racine et les fichiers cachés if name == "Chantiers" or name.startswith("."): continue # Vérifier que c'est un dossier (resourcetype contient d:collection) resourcetype = resp.find(".//d:resourcetype", ns) if resourcetype is not None and resourcetype.find("d:collection", ns) is not None: dossiers.append(name) # Trier par date décroissante (format YYYYMMDD_ en début de nom) dossiers.sort(reverse=True) return dossiers[:max_results] # handlers/fin_chantier.py from telegram import InlineKeyboardButton, InlineKeyboardMarkup from services.nextcloud import get_chantiers_actifs async def afficher_choix_chantier(update, context): """Afficher la liste des chantiers Nextcloud comme boutons""" chantiers = await get_chantiers_actifs( base_url=config.NC_URL, user=config.NC_USER, password=config.NC_PASS ) # Créer les boutons (1 par ligne pour lisibilité sur mobile) keyboard = [ [InlineKeyboardButton( text=formater_nom(c), # ex: "Müller — 25/03/2026" callback_data=f"chantier_{c}" )] for c in chantiers ] # Bouton pour un chantier non listé keyboard.append([InlineKeyboardButton( "✏️ Autre chantier (saisie manuelle)", callback_data="chantier_manuel" )]) await update.callback_query.message.reply_text( "🏠 Quel chantier ?", reply_markup=InlineKeyboardMarkup(keyboard) ) return SELECTION_CHANTIER def formater_nom(dossier: str) -> str: """20260325_Müller → Müller — 25/03/2026""" if len(dossier) >= 9 and dossier[:8].isdigit(): date_str = dossier[:8] nom = dossier[9:] date_fmt = f"{date_str[6:8]}/{date_str[4:6]}/{date_str[:4]}" return f"{nom} — {date_fmt}" return dossier ``` ### États ConversationHandler mis à jour ```python MENU = 0 SELECTION_CHANTIER = 1 # Nouveau — choix du chantier ATTENTE_PHOTO_FIN = 2 ATTENTE_CONTENU_SAV = 3 ATTENTE_CONTENU_MATERIEL = 4 SAISIE_MANUELLE = 5 # Si chantier non listé ``` ### Affichage sur Telegram ``` 🏠 Quel chantier ? [Müller — 25/03/2026 ] [Hartmann — 22/03/2026 ] [Weber — 18/03/2026 ] [Schmitt — 15/03/2026 ] [✏️ Autre chantier (saisie manuelle)] ``` --- ## Phase 2 — FAQ SAV avec RAG (sans LLM complet) ### Architecture recommandée Pas besoin d'un LLM lourd — **RAG léger avec ChromaDB** suffit et tourne sur le Proxmox existant (ou Pi 5 si besoin). ``` Document SAV entrant → ChromaDB cherche les 3 passages les plus proches → Bot envoie les extraits directement au technicien → (optionnel) Ollama reformule si VM Proxmox disponible ``` ### Matériel | Option | Matériel | Latence | Verdict | |---|---|---|---| | Recherche vectorielle seule | Pi 4 4GB | < 1s | ✅ Parfait | | RAG + petit modèle (phi3-mini) | Pi 5 8GB | 15-30s | ⚠️ Acceptable | | RAG + Mistral 7B | VM Proxmox 16GB RAM | 3-8s | ✅ Recommandé | | API distante (Groq/Claude) | N'importe quoi | < 2s | ✅ Le plus simple | ### Recommandation pour ETM Utiliser **Ollama sur le Proxmox existant** — il est déjà là, pas besoin d'investir dans un Pi supplémentaire. ```python # services/llm.py import chromadb import httpx class SAVAssistant: def __init__(self): self.db = chromadb.PersistentClient(path="./sav_knowledge") self.collection = self.db.get_or_create_collection("docs_techniques") async def indexer_document(self, texte: str, source: str): """Indexer une notice technique, un rapport SAV, etc.""" # Découper en chunks de 500 mots chunks = [texte[i:i+500] for i in range(0, len(texte), 400)] self.collection.add( documents=chunks, ids=[f"{source}_{i}" for i in range(len(chunks))], metadatas=[{"source": source}] * len(chunks) ) async def chercher(self, question: str, n_results=3) -> str: """Chercher les passages pertinents pour un problème SAV""" results = self.collection.query( query_texts=[question], n_results=n_results ) extraits = results["documents"][0] sources = [m["source"] for m in results["metadatas"][0]] # Reformuler avec Ollama si disponible try: response = await httpx.AsyncClient().post( "http://localhost:11434/api/generate", json={ "model": "phi3", "prompt": f"Problème terrain : {question}\n\nDocumentation :\n" + "\n\n".join(extraits) + "\n\nDonne 3 pistes de diagnostic en français, " "en langage simple pour un technicien.", "stream": False }, timeout=30 ) return response.json()["response"] except: # Si Ollama indisponible, retourner les extraits bruts return "\n\n---\n\n".join( [f"📄 {s}\n{e}" for s, e in zip(sources, extraits)] ) ``` ### Documents à indexer en priorité - Notices onduleurs : Fronius, SMA, Huawei, Enphase - Notices batteries : BYD, Pylontech, Soltaro - Notices PAC : notices d'installation et de dépannage - Bornes IRVE : Keba, Wallbox, ETM-PowerCharger - Historique SAV ETM : rapports d'intervention passés (mine d'or !)