421 lines
14 KiB
Markdown
421 lines
14 KiB
Markdown
# 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"""<?xml version="1.0"?>
|
||
<d:propfind xmlns:d="DAV:">
|
||
<d:prop><d:displayname/><d:resourcetype/></d:prop>
|
||
</d:propfind>"""
|
||
)
|
||
|
||
# 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 !)
|