Prechádzať zdrojové kódy

feat: réponse aux emails via langage naturel

- Ajout scope gmail.send
- get_email_body() pour lire le contenu complet
- send_email_reply() pour envoyer via Gmail API
- Détection d'intention 'repondre_mail'
- Flow avec confirmation avant envoi
Brin 1 mesiac pred
rodič
commit
1c641fd230
1 zmenil súbory, kde vykonal 113 pridanie a 1 odobranie
  1. 113 1
      bot.py

+ 113 - 1
bot.py

@@ -280,6 +280,7 @@ async def detect_intent(text: str) -> dict:
         '{"intent":"calendrier"}                      — agenda Google Calendar\n'
         '{"intent":"mails"}                           — mails Gmail non lus\n'
         '{"intent":"rappel","time":"30m","message":"texte"} — créer un rappel\n'
+        '{"intent":"repondre_mail","expediteur":"nom ou email","consigne":"ce qu il faut dire"} — répondre à un email\n'
         '{"intent":"conversation"}                    — tout le reste\n\n'
         f'Message : "{text}"\n\nJSON :'
     )
@@ -386,6 +387,49 @@ async def execute_intent(intent_data: dict, user_id: int, update, context) -> bo
         await update.message.reply_text(f"⏰ Rappel créé : \"{message}\" à {target_time.strftime('%H:%M le %d/%m')}")
         return True
 
+    elif intent == "repondre_mail":
+        expediteur = intent_data.get("expediteur", "")
+        consigne = intent_data.get("consigne", "")
+        emails = get_gmail_messages(max_results=10)
+        if emails is None:
+            await update.message.reply_text("Google non connecté. Lance /google_connect")
+            return True
+        # Trouver l'email correspondant
+        email_found = None
+        for e in emails:
+            if expediteur.lower() in e["from"].lower() or expediteur.lower() in e["subject"].lower():
+                email_found = e
+                break
+        if not email_found:
+            await update.message.reply_text(f"Je n'ai pas trouvé d'email de \"{expediteur}\" dans tes non-lus.")
+            return True
+        # Récupérer le corps
+        await update.message.reply_text(f"📧 Email trouvé de {email_found['from']}\nSujet : {email_found['subject']}\n\nRédaction de la réponse...")
+        body = get_email_body(email_found["id"])
+        prompt = (
+            f"Tu dois rédiger une réponse à cet email en français.\n\n"
+            f"De : {email_found['from']}\n"
+            f"Sujet : {email_found['subject']}\n"
+            f"Contenu : {body}\n\n"
+            f"Consigne : {consigne}\n\n"
+            "Rédige uniquement le corps de la réponse, sans salutation excessive, de façon professionnelle et concise."
+        )
+        reply, _ = await ask_agent(user_id, prompt, "konan")
+        # Stocker en attente de confirmation
+        pending_replies[user_id] = {
+            "to": email_found["from"],
+            "subject": email_found["subject"],
+            "thread_id": email_found["thread_id"],
+            "message_id": email_found["message_id"],
+            "draft": reply
+        }
+        await update.message.reply_text(
+            f"📝 *Voici ma réponse proposée :*\n\n{reply}\n\n"
+            "Réponds *oui* pour envoyer, *non* pour annuler.",
+            parse_mode="Markdown"
+        )
+        return True
+
     return False
 
 # ─── Ollama ───────────────────────────────────────────────────────────────────
@@ -525,12 +569,64 @@ def get_gmail_messages(max_results=5):
         ).execute()
         headers = {h["name"]: h["value"] for h in detail["payload"]["headers"]}
         emails.append({
+            "id": msg["id"],
+            "thread_id": detail.get("threadId", ""),
+            "message_id": headers.get("Message-ID", ""),
             "from": headers.get("From", ""),
             "subject": headers.get("Subject", ""),
             "date": headers.get("Date", "")
         })
     return emails
 
+def get_email_body(msg_id: str) -> str:
+    creds = get_google_creds()
+    if not creds:
+        return ""
+    try:
+        service = build("gmail", "v1", credentials=creds)
+        detail = service.users().messages().get(userId="me", id=msg_id, format="full").execute()
+        parts = detail.get("payload", {}).get("parts", [])
+        for part in parts:
+            if part.get("mimeType") == "text/plain":
+                data = part["body"].get("data", "")
+                import base64 as b64
+                return b64.urlsafe_b64decode(data + "==").decode("utf-8", errors="ignore")[:1500]
+        # Pas de parts — corps direct
+        body_data = detail.get("payload", {}).get("body", {}).get("data", "")
+        if body_data:
+            import base64 as b64
+            return b64.urlsafe_b64decode(body_data + "==").decode("utf-8", errors="ignore")[:1500]
+    except Exception:
+        pass
+    return ""
+
+def send_email_reply(to: str, subject: str, body: str, thread_id: str, in_reply_to: str) -> bool:
+    import email.mime.text
+    import base64 as b64
+    creds = get_google_creds()
+    if not creds:
+        return False
+    try:
+        service = build("gmail", "v1", credentials=creds)
+        msg = email.mime.text.MIMEText(body)
+        msg["To"] = to
+        msg["Subject"] = subject if subject.startswith("Re:") else f"Re: {subject}"
+        if in_reply_to:
+            msg["In-Reply-To"] = in_reply_to
+            msg["References"] = in_reply_to
+        raw = b64.urlsafe_b64encode(msg.as_bytes()).decode()
+        service.users().messages().send(
+            userId="me",
+            body={"raw": raw, "threadId": thread_id}
+        ).execute()
+        return True
+    except Exception as e:
+        logger.error(f"Erreur envoi email : {e}")
+        return False
+
+# En attente de confirmation d'envoi d'email
+pending_replies: dict = {}
+
 # ─── Notes ────────────────────────────────────────────────────────────────────
 
 def notes_path(user_id):
@@ -1099,6 +1195,21 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
             await update.message.reply_text(f"📝 Noté ! (#{note['id']}) : \"{content}\"")
             return
 
+    # Confirmation d'envoi d'email en attente
+    if user_id in pending_replies:
+        if text_lower.strip() in ["oui", "yes", "envoie", "ok", "confirme"]:
+            pr = pending_replies.pop(user_id)
+            ok = send_email_reply(pr["to"], pr["subject"], pr["draft"], pr["thread_id"], pr["message_id"])
+            if ok:
+                await update.message.reply_text("✅ Email envoyé !")
+            else:
+                await update.message.reply_text("Erreur lors de l'envoi. Vérifie la connexion Google.")
+            return
+        elif text_lower.strip() in ["non", "no", "annule", "cancel"]:
+            pending_replies.pop(user_id)
+            await update.message.reply_text("❌ Email annulé.")
+            return
+
     await update.message.reply_text("⏳")
     intent_data = await detect_intent(text)
     if await execute_intent(intent_data, user_id, update, context):
@@ -1159,7 +1270,8 @@ GOOGLE_CREDS_FILE = os.path.expanduser("~/agent/google_credentials.json")
 GOOGLE_TOKEN_FILE = os.path.expanduser("~/agent/google_token.json")
 GOOGLE_SCOPES = [
     "https://www.googleapis.com/auth/calendar.readonly",
-    "https://www.googleapis.com/auth/gmail.readonly"
+    "https://www.googleapis.com/auth/gmail.readonly",
+    "https://www.googleapis.com/auth/gmail.send"
 ]
 
 # Stockage temporaire des flows OAuth en attente