Prechádzať zdrojové kódy

feat: initial Konan AI assistant

Bot Telegram avec :
- Multi-agents (konan, coder, reviewer, architect, analyst)
- Voix (Whisper STT + Piper TTS fr)
- Memoire persistante par utilisateur
- Google Calendar + Gmail
- Monitoring serveur, rappels, briefing matinal
- Meteo, news, recherche web
- Git, vision (images), notes
- API HTTP port 8765 (client vocal PC)
- Interface vocale directe serveur
- Docker + install.sh pour migration facile
Brin 1 mesiac pred
commit
fbb35a925c
10 zmenil súbory, kde vykonal 1746 pridanie a 0 odobranie
  1. 2 0
      .env.example
  2. 23 0
      .gitignore
  3. 23 0
      Dockerfile
  4. 151 0
      README.md
  5. 1210 0
      bot.py
  6. 45 0
      docker-compose.yml
  7. 70 0
      install.sh
  8. 93 0
      konan_client.py
  9. 113 0
      konan_voice.py
  10. 16 0
      requirements.txt

+ 2 - 0
.env.example

@@ -0,0 +1,2 @@
+# Copie ce fichier en .env et remplis les valeurs
+TELEGRAM_TOKEN=ton_token_telegram_ici

+ 23 - 0
.gitignore

@@ -0,0 +1,23 @@
+# Secrets
+.env
+google_credentials.json
+google_token.json
+
+# Donnees utilisateur (ne pas versionner)
+memory/
+repos/
+reminders.json
+briefing_config.json
+
+# Modeles de voix (trop lourds pour git)
+voices/*.onnx
+voices/*.json
+
+# Python
+__pycache__/
+*.pyc
+venv/
+
+# Divers
+*.log
+.DS_Store

+ 23 - 0
Dockerfile

@@ -0,0 +1,23 @@
+FROM python:3.11-slim
+
+# Dependances systeme
+RUN apt-get update && apt-get install -y \
+    ffmpeg \
+    libportaudio2 \
+    git \
+    wget \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Dependances Python
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Code du bot
+COPY bot.py .
+
+# Dossiers de donnees (montes en volumes)
+RUN mkdir -p memory voices repos
+
+CMD ["python3", "bot.py"]

+ 151 - 0
README.md

@@ -0,0 +1,151 @@
+# Konan — Assistant IA Personnel
+
+Konan est un assistant IA personnel style JARVIS, 100% local, accessible via Telegram et via une interface vocale directe (micro/enceintes).
+
+## Architecture
+
+```
+Telegram / Micro
+      |
+   bot.py  <──>  Ollama (LLM local)
+      |
+  ┌───┴───────────────────────────────┐
+  │  Agents : konan / coder /         │
+  │  reviewer / architect / analyst   │
+  └───────────────────────────────────┘
+      |
+  Memoire JSON  |  Google Calendar/Gmail
+  Rappels       |  Meteo / News / Recherche web
+  Git           |  Monitoring serveur
+  Vision        |  API HTTP (port 8765)
+```
+
+## Prerequis
+
+- Debian/Ubuntu (ou Docker)
+- Python 3.11+
+- 4 Go RAM minimum (8 Go recommande)
+- Microphone + enceintes (pour l'interface vocale)
+- Token Telegram Bot (via @BotFather)
+- Credentials Google OAuth (optionnel, pour Calendar + Gmail)
+
+## Installation rapide (Docker)
+
+```bash
+# 1. Cloner / copier les fichiers
+git clone <ton-repo> konan && cd konan
+
+# 2. Configurer le token Telegram dans bot.py
+#    Ligne : TELEGRAM_TOKEN = "TON_TOKEN"
+
+# 3. Lancer
+docker compose up -d
+
+# 4. Tirer les modeles LLM (a faire une seule fois)
+docker exec konan-ollama ollama pull qwen2.5:3b
+docker exec konan-ollama ollama pull qwen2.5-coder:3b
+
+# 5. Voir les logs
+docker compose logs -f konan-bot
+```
+
+## Installation manuelle (sans Docker)
+
+```bash
+# Copier tous les fichiers dans ~/agent/
+cp bot.py konan_voice.py ~/agent/
+
+# Lancer le script d'installation
+bash install.sh
+```
+
+## Transfert vers un nouveau serveur
+
+```bash
+# Sur l'ancien serveur — copier les donnees
+scp -r ~/agent/ user@NOUVEAU_SERVEUR:~/
+scp -r ~/.ollama/ user@NOUVEAU_SERVEUR:~/   # modeles LLM (optionnel, ~4 Go)
+
+# Sur le nouveau serveur — reinstaller
+bash ~/agent/install.sh
+```
+
+## Interface vocale PC (Windows)
+
+Installe les dependances :
+```
+pip install sounddevice scipy openai-whisper requests keyboard pyttsx3
+```
+
+Lance :
+```
+python konan_client.py
+```
+Maintiens **Espace** pour parler, relache pour envoyer.
+
+## Interface vocale directe (sur le serveur)
+
+```bash
+cd ~/agent && venv/bin/python3 konan_voice.py
+```
+Parle naturellement — Konan detecte automatiquement ta voix et repond a voix haute.
+
+## Commandes Telegram
+
+| Commande | Description |
+|----------|-------------|
+| `/start` | Demarrer / aide |
+| `/agents` | Liste des agents disponibles |
+| `/setnom Prenom` | Definir ton prenom |
+| `/setprojet NomProjet` | Ajouter un projet |
+| `/profil` | Voir ton profil |
+| `/monitoring` | Etat du serveur (CPU, RAM, disque) |
+| `/rappel 30m Texte` | Programmer un rappel |
+| `/rappels` | Voir les rappels actifs |
+| `/briefing HH:MM` | Programmer un briefing matinal |
+| `/meteo` | Meteo actuelle |
+| `/news` | Actualites du jour |
+| `/recherche Requete` | Recherche web |
+| `/note Texte` | Sauvegarder une note |
+| `/notes` | Voir toutes les notes |
+| `/chercher Mot` | Rechercher dans la memoire |
+| `/repos` | Lister les repos Git |
+| `/clone URL` | Cloner un repo |
+| `/calendrier` | Evenements Google Calendar |
+| `/mails` | Mails non lus Gmail |
+| `/google_connect` | Connecter Google |
+| `/reset` | Reinitialiser la conversation |
+
+## Structure des fichiers
+
+```
+~/agent/
+  bot.py                    # Bot Telegram principal
+  konan_voice.py            # Interface vocale serveur
+  konan_client.py           # Interface vocale PC Windows
+  reminders.json            # Rappels persistes
+  briefing_config.json      # Config briefing matinal
+  google_credentials.json   # Credentials OAuth Google
+  google_token.json         # Token Google (apres auth)
+  memory/                   # Conversations et profils par utilisateur
+  voices/                   # Modeles de voix Piper
+  repos/                    # Repos Git clones
+```
+
+## Modeles LLM utilises
+
+| Modele | Usage | Taille |
+|--------|-------|--------|
+| qwen2.5:3b | Conversation generale (Konan) | ~2 Go |
+| qwen2.5-coder:3b | Code (Coder, Reviewer) | ~2 Go |
+
+## Ajouter un GPU
+
+Si ton nouveau serveur a un GPU Nvidia, decommente la section `deploy` dans `docker-compose.yml` — les reponses passeront de ~30s a ~2s.
+
+## Variables d'environnement (Docker)
+
+| Variable | Defaut | Description |
+|----------|--------|-------------|
+| `OLLAMA_URL` | `http://ollama:11434/api/chat` | URL du serveur Ollama |
+| `OLLAMA_GENERATE_URL` | `http://ollama:11434/api/generate` | URL generation Ollama |

+ 1210 - 0
bot.py

@@ -0,0 +1,1210 @@
+import logging
+import json
+import os
+import tempfile
+import subprocess
+import base64
+import httpx
+import whisper
+import psutil
+import feedparser
+from datetime import datetime, timedelta
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from duckduckgo_search import DDGS
+from telegram import Update, Bot
+from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, filters, ContextTypes
+from google.oauth2.credentials import Credentials
+from google_auth_oauthlib.flow import Flow
+from googleapiclient.discovery import build
+from google.auth.transport.requests import Request
+from aiohttp import web
+
+TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN", "")
+if not TELEGRAM_TOKEN:
+    raise RuntimeError("TELEGRAM_TOKEN non defini. Ajoute-le dans .env ou en variable d'environnement.")
+OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/chat")
+OLLAMA_GENERATE_URL = os.environ.get("OLLAMA_GENERATE_URL", "http://localhost:11434/api/generate")
+MEMORY_DIR = os.path.expanduser("~/agent/memory")
+REPOS_DIR = os.path.expanduser("~/agent/repos")
+VOICE_MODEL = os.path.expanduser("~/agent/voices/fr_FR-upmc-medium.onnx")
+PIPER_BIN = os.path.expanduser("~/agent/venv/bin/piper")
+REMINDERS_FILE = os.path.join(os.path.expanduser("~/agent"), "reminders.json")
+DEFAULT_CITY = "Nantes"
+
+NEWS_FEEDS = {
+    "tech": [
+        "https://www.01net.com/rss/",
+        "https://www.lemonde.fr/pixels/rss_full.xml",
+        "https://www.zdnet.fr/feeds/rss/actualites/",
+    ],
+    "politique": [
+        "https://www.lemonde.fr/politique/rss_full.xml",
+        "https://www.rfi.fr/fr/rss/politique-fr/",
+        "https://www.francetvinfo.fr/politique.rss",
+    ],
+    "football": [
+        "https://www.lequipe.fr/rss/actu_rss_Football.xml",
+        "https://www.bbc.co.uk/sport/football/european/rss.xml",
+    ]
+}
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+os.makedirs(MEMORY_DIR, exist_ok=True)
+os.makedirs(REPOS_DIR, exist_ok=True)
+
+scheduler = AsyncIOScheduler()
+
+# ─── Agents ───────────────────────────────────────────────────────────────────
+
+AGENTS = {
+    "konan": {
+        "model": "qwen2.5:3b",
+        "prompt": (
+            "Tu es Konan, orchestrateur d'agents IA et développeur senior. "
+            "Tu adaptes ton ton naturellement selon le contexte : "
+            "décontracté et amical pour une conversation, précis et direct pour du technique, "
+            "structuré et rigoureux pour de l'architecture, encourageant pour du brainstorming. "
+            "Tu parles toujours en français. Tu ne te présentes pas à chaque message. "
+            "Quand une demande nécessite un expert spécialisé, tu le dis et tu délègues. "
+            "Tu es concis : pas de blabla inutile, tu vas droit au but."
+        )
+    },
+    "coder": {
+        "model": "qwen2.5-coder:3b",
+        "prompt": (
+            "Tu es l'agent Coder de Konan, expert en développement logiciel. "
+            "Tu écris du code propre, lisible et bien commenté. "
+            "Tu expliques tes choix techniques brièvement. "
+            "Tu signales les bugs potentiels et proposes des améliorations. "
+            "Tu réponds en français, avec le code dans des blocs formatés."
+        )
+    },
+    "reviewer": {
+        "model": "qwen2.5-coder:3b",
+        "prompt": (
+            "Tu es l'agent Reviewer de Konan, expert en revue de code. "
+            "Tu analyses le code de façon critique et constructive. "
+            "Tu identifies : bugs, failles de sécurité, mauvaises pratiques, performance. "
+            "Tu proposes des corrections concrètes avec des exemples. "
+            "Sois direct et précis. Tu réponds en français."
+        )
+    },
+    "architect": {
+        "model": "qwen2.5:3b",
+        "prompt": (
+            "Tu es l'agent Architect de Konan, expert en architecture logicielle et systèmes IA. "
+            "Tu penses en termes de scalabilité, maintenabilité et simplicité. "
+            "Tu proposes des architectures claires avec des schémas textuels si nécessaire. "
+            "Tu justifies tes choix et présentes les alternatives. "
+            "Tu réponds en français de façon structurée."
+        )
+    },
+    "analyst": {
+        "model": "qwen2.5:3b",
+        "prompt": (
+            "Tu es l'agent Analyst de Konan, expert en analyse et documentation. "
+            "Tu synthétises l'information de façon claire et structurée. "
+            "Tu produis des analyses, des résumés, de la documentation technique. "
+            "Tu es méthodique et exhaustif sans être verbeux. Tu réponds en français."
+        )
+    }
+}
+
+AGENT_EMOJIS = {"konan": "🧠", "coder": "💻", "reviewer": "🔍", "architect": "🏗️", "analyst": "📊"}
+
+# ─── Profil ───────────────────────────────────────────────────────────────────
+
+def profile_path(user_id):
+    return os.path.join(MEMORY_DIR, f"{user_id}_profile.json")
+
+def load_profile(user_id):
+    path = profile_path(user_id)
+    if os.path.exists(path):
+        with open(path, "r") as f:
+            return json.load(f)
+    return {"nom": None, "projets": [], "preferences": ""}
+
+def save_profile(user_id, profile):
+    with open(profile_path(user_id), "w") as f:
+        json.dump(profile, f, ensure_ascii=False, indent=2)
+
+# ─── Mémoire ──────────────────────────────────────────────────────────────────
+
+def memory_path(user_id, agent="konan"):
+    return os.path.join(MEMORY_DIR, f"{user_id}_{agent}.json")
+
+def build_system_prompt(user_id, agent="konan"):
+    profile = load_profile(user_id)
+    nom = profile.get("nom")
+    projets = profile.get("projets", [])
+    base = AGENTS[agent]["prompt"]
+    if nom:
+        base += f" Tu parles à {nom}."
+    if projets:
+        base += f" Ses projets : {', '.join(projets)}."
+    return base
+
+def load_conversation(user_id, agent="konan"):
+    path = memory_path(user_id, agent)
+    system = build_system_prompt(user_id, agent)
+    if os.path.exists(path):
+        msgs = json.load(open(path))
+        msgs[0] = {"role": "system", "content": system}
+        return msgs
+    return [{"role": "system", "content": system}]
+
+def save_conversation(user_id, messages, agent="konan"):
+    with open(memory_path(user_id, agent), "w") as f:
+        json.dump(messages, f, ensure_ascii=False, indent=2)
+
+# ─── Monitoring ───────────────────────────────────────────────────────────────
+
+def get_server_status():
+    cpu = psutil.cpu_percent(interval=1)
+    ram = psutil.virtual_memory()
+    disk = psutil.disk_usage("/")
+    temps = psutil.sensors_temperatures() if hasattr(psutil, "sensors_temperatures") else {}
+
+    ram_used = ram.used / (1024**3)
+    ram_total = ram.total / (1024**3)
+    disk_used = disk.used / (1024**3)
+    disk_total = disk.total / (1024**3)
+
+    cpu_emoji = "🔴" if cpu > 80 else "🟡" if cpu > 50 else "🟢"
+    ram_emoji = "🔴" if ram.percent > 80 else "🟡" if ram.percent > 60 else "🟢"
+    disk_emoji = "🔴" if disk.percent > 90 else "🟡" if disk.percent > 75 else "🟢"
+
+    msg = f"📊 *État du serveur*\n\n"
+    msg += f"{cpu_emoji} CPU : {cpu:.1f}%\n"
+    msg += f"{ram_emoji} RAM : {ram_used:.1f}/{ram_total:.1f} GB ({ram.percent}%)\n"
+    msg += f"{disk_emoji} Disque : {disk_used:.1f}/{disk_total:.1f} GB ({disk.percent}%)\n"
+
+    # Processus les plus gourmands
+    procs = sorted(psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]),
+                   key=lambda p: p.info["cpu_percent"] or 0, reverse=True)[:3]
+    if procs:
+        msg += "\n🔝 *Top processus CPU :*\n"
+        for p in procs:
+            msg += f"  {p.info['name']} — CPU {p.info['cpu_percent']:.1f}% RAM {p.info['memory_percent']:.1f}%\n"
+
+    # Ollama actif ?
+    ollama_running = any("ollama" in p.info["name"].lower() for p in psutil.process_iter(["name"]))
+    msg += f"\n🤖 Ollama : {'✅ actif' if ollama_running else '❌ inactif'}\n"
+    msg += f"🕐 Uptime : {datetime.now().strftime('%H:%M:%S')}"
+
+    return msg
+
+# ─── Rappels ──────────────────────────────────────────────────────────────────
+
+def load_reminders():
+    if os.path.exists(REMINDERS_FILE):
+        with open(REMINDERS_FILE, "r") as f:
+            return json.load(f)
+    return []
+
+def save_reminders(reminders):
+    with open(REMINDERS_FILE, "w") as f:
+        json.dump(reminders, f, ensure_ascii=False, indent=2)
+
+async def send_reminder(bot: Bot, user_id: int, message: str, reminder_id: str):
+    try:
+        await bot.send_message(chat_id=user_id, text=f"⏰ Rappel : {message}")
+        # Supprimer le rappel après envoi
+        reminders = load_reminders()
+        reminders = [r for r in reminders if r["id"] != reminder_id]
+        save_reminders(reminders)
+    except Exception as e:
+        logger.error(f"Erreur rappel : {e}")
+
+def parse_time(time_str: str):
+    """Parse '30m', '2h', '10:30' ou 'demain 9h'"""
+    now = datetime.now()
+    time_str = time_str.strip().lower()
+
+    if time_str.endswith("m"):
+        minutes = int(time_str[:-1])
+        return now + timedelta(minutes=minutes)
+    elif time_str.endswith("h") and ":" not in time_str:
+        hours = int(time_str[:-1])
+        return now + timedelta(hours=hours)
+    elif ":" in time_str:
+        h, m = time_str.split(":")
+        target = now.replace(hour=int(h), minute=int(m), second=0)
+        if target < now:
+            target += timedelta(days=1)
+        return target
+    return None
+
+# ─── Détection d'intention ────────────────────────────────────────────────────
+
+async def detect_agent(message: str) -> str:
+    prompt = (
+        "Analyse cette demande et réponds UNIQUEMENT par un de ces mots : "
+        "konan, coder, reviewer, architect, analyst.\n\n"
+        "- konan : conversation, question générale\n"
+        "- coder : écrire du code, corriger un bug\n"
+        "- reviewer : relire du code, code review\n"
+        "- architect : architecture, conception, design\n"
+        "- analyst : analyser, documenter, résumer\n\n"
+        f"Demande : {message}\n\nRéponds avec un seul mot."
+    )
+    try:
+        async with httpx.AsyncClient(timeout=30) as client:
+            response = await client.post(OLLAMA_URL, json={
+                "model": "qwen2.5:3b",
+                "messages": [{"role": "user", "content": prompt}],
+                "stream": False,
+                "options": {"num_predict": 5}
+            })
+            result = response.json()["message"]["content"].strip().lower()
+            for agent in AGENTS:
+                if agent in result:
+                    return agent
+    except Exception:
+        pass
+    return "konan"
+
+# ─── Ollama ───────────────────────────────────────────────────────────────────
+
+async def ask_agent(user_id, message, agent="konan"):
+    messages = load_conversation(user_id, agent)
+    messages.append({"role": "user", "content": message})
+    if len(messages) > 21:
+        messages = [messages[0]] + messages[-20:]
+    try:
+        async with httpx.AsyncClient(timeout=180) as client:
+            response = await client.post(OLLAMA_URL, json={
+                "model": AGENTS[agent]["model"],
+                "messages": messages,
+                "stream": False
+            })
+            data = response.json()
+            reply = data["message"]["content"]
+            messages.append({"role": "assistant", "content": reply})
+            save_conversation(user_id, messages, agent)
+            return reply, agent
+    except Exception as e:
+        return f"Erreur : {e}", agent
+
+# ─── Vision ───────────────────────────────────────────────────────────────────
+
+async def analyze_image(image_path: str, question: str = "Décris cette image en détail en français.") -> str:
+    with open(image_path, "rb") as f:
+        image_b64 = base64.b64encode(f.read()).decode("utf-8")
+    try:
+        async with httpx.AsyncClient(timeout=180) as client:
+            response = await client.post(OLLAMA_GENERATE_URL, json={
+                "model": "moondream",
+                "prompt": question,
+                "images": [image_b64],
+                "stream": False
+            })
+            return response.json().get("response", "Impossible d'analyser l'image.")
+    except Exception as e:
+        return f"Erreur analyse image : {e}"
+
+# ─── TTS / STT ────────────────────────────────────────────────────────────────
+
+logger.info("Chargement Whisper...")
+whisper_model = whisper.load_model("tiny")
+logger.info("Whisper pret.")
+
+def text_to_audio(text):
+    tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
+    tmp.close()
+    subprocess.run(
+        [PIPER_BIN, "--model", VOICE_MODEL, "--output_file", tmp.name],
+        input=text.encode("utf-8"), check=True
+    )
+    return tmp.name
+
+async def transcribe_voice(file_path):
+    result = whisper_model.transcribe(file_path, language="fr")
+    return result["text"].strip()
+
+# ─── Git ──────────────────────────────────────────────────────────────────────
+
+def list_repos():
+    if not os.path.exists(REPOS_DIR):
+        return []
+    return [d for d in os.listdir(REPOS_DIR) if os.path.isdir(os.path.join(REPOS_DIR, d, ".git"))]
+
+def list_files(repo):
+    repo_path = os.path.join(REPOS_DIR, repo)
+    if not os.path.exists(repo_path):
+        return None
+    result = subprocess.run(["git", "ls-files"], cwd=repo_path, capture_output=True, text=True)
+    return [f for f in result.stdout.strip().split("\n") if f]
+
+def read_file(repo, filepath):
+    full_path = os.path.join(REPOS_DIR, repo, filepath)
+    if not os.path.exists(full_path):
+        return None
+    with open(full_path, "r", errors="replace") as f:
+        return f.read()
+
+def git_log(repo, n=5):
+    result = subprocess.run(["git", "log", f"-{n}", "--oneline"],
+                            cwd=os.path.join(REPOS_DIR, repo), capture_output=True, text=True)
+    return result.stdout.strip()
+
+def git_pull(repo):
+    result = subprocess.run(["git", "pull"],
+                            cwd=os.path.join(REPOS_DIR, repo), capture_output=True, text=True)
+    return result.stdout.strip() or result.stderr.strip()
+
+# ─── Google Auth ──────────────────────────────────────────────────────────────
+
+def get_google_creds():
+    creds = None
+    if os.path.exists(GOOGLE_TOKEN_FILE):
+        creds = Credentials.from_authorized_user_file(GOOGLE_TOKEN_FILE, GOOGLE_SCOPES)
+    if creds and creds.expired and creds.refresh_token:
+        creds.refresh(Request())
+        with open(GOOGLE_TOKEN_FILE, "w") as f:
+            f.write(creds.to_json())
+    return creds if (creds and creds.valid) else None
+
+# ─── Google Calendar ──────────────────────────────────────────────────────────
+
+def get_calendar_events(max_results=10):
+    creds = get_google_creds()
+    if not creds:
+        return None
+    service = build("calendar", "v3", credentials=creds)
+    now = datetime.utcnow().isoformat() + "Z"
+    events_result = service.events().list(
+        calendarId="primary",
+        timeMin=now,
+        maxResults=max_results,
+        singleEvents=True,
+        orderBy="startTime"
+    ).execute()
+    return events_result.get("items", [])
+
+# ─── Gmail ────────────────────────────────────────────────────────────────────
+
+def get_gmail_messages(max_results=5):
+    creds = get_google_creds()
+    if not creds:
+        return None
+    service = build("gmail", "v1", credentials=creds)
+    result = service.users().messages().list(
+        userId="me", maxResults=max_results, q="is:unread"
+    ).execute()
+    messages = result.get("messages", [])
+    emails = []
+    for msg in messages:
+        detail = service.users().messages().get(
+            userId="me", id=msg["id"], format="metadata",
+            metadataHeaders=["From", "Subject", "Date"]
+        ).execute()
+        headers = {h["name"]: h["value"] for h in detail["payload"]["headers"]}
+        emails.append({
+            "from": headers.get("From", ""),
+            "subject": headers.get("Subject", ""),
+            "date": headers.get("Date", "")
+        })
+    return emails
+
+# ─── Notes ────────────────────────────────────────────────────────────────────
+
+def notes_path(user_id):
+    return os.path.join(MEMORY_DIR, f"{user_id}_notes.json")
+
+def load_notes(user_id):
+    path = notes_path(user_id)
+    if os.path.exists(path):
+        with open(path, "r") as f:
+            return json.load(f)
+    return []
+
+def save_notes(user_id, notes):
+    with open(notes_path(user_id), "w") as f:
+        json.dump(notes, f, ensure_ascii=False, indent=2)
+
+def add_note(user_id, content):
+    notes = load_notes(user_id)
+    note = {
+        "id": len(notes) + 1,
+        "content": content,
+        "date": datetime.now().strftime("%Y-%m-%d %H:%M")
+    }
+    notes.append(note)
+    save_notes(user_id, notes)
+    return note
+
+def search_notes(user_id, query):
+    notes = load_notes(user_id)
+    query_lower = query.lower()
+    return [n for n in notes if query_lower in n["content"].lower()]
+
+def search_memory(user_id, query):
+    results = []
+    query_lower = query.lower()
+    # Chercher dans toutes les conversations
+    for agent in AGENTS:
+        path = memory_path(user_id, agent)
+        if not os.path.exists(path):
+            continue
+        msgs = json.load(open(path))
+        for msg in msgs:
+            if msg["role"] in ("user", "assistant") and query_lower in msg["content"].lower():
+                results.append({
+                    "agent": agent,
+                    "role": msg["role"],
+                    "content": msg["content"][:300]
+                })
+    # Chercher dans les notes
+    notes = search_notes(user_id, query)
+    return results, notes
+
+# ─── Météo ────────────────────────────────────────────────────────────────────
+
+async def get_weather(city: str) -> str:
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            response = await client.get(f"https://wttr.in/{city}?format=3&lang=fr")
+            return f"🌤️ {response.text.strip()}"
+    except Exception as e:
+        return f"Impossible de récupérer la météo : {e}"
+
+# ─── Recherche web ────────────────────────────────────────────────────────────
+
+def web_search(query: str, max_results: int = 5) -> str:
+    try:
+        with DDGS() as ddgs:
+            results = list(ddgs.text(query, region="fr-fr", max_results=max_results))
+        if not results:
+            return "Aucun résultat trouvé."
+        msg = ""
+        for r in results:
+            msg += f"• *{r.get('title','')}*\n{r.get('body','')[:200]}\n\n"
+        return msg.strip()
+    except Exception as e:
+        return f"Erreur recherche : {e}"
+
+# ─── Actualités ───────────────────────────────────────────────────────────────
+
+def get_news(category: str = "tech", max_items: int = 5) -> str:
+    feeds = NEWS_FEEDS.get(category, NEWS_FEEDS["tech"])
+    items = []
+    for url in feeds:
+        try:
+            feed = feedparser.parse(url)
+            for entry in feed.entries[:3]:
+                items.append({
+                    "title": entry.get("title", ""),
+                    "summary": entry.get("summary", "")[:200]
+                })
+        except Exception:
+            continue
+    if not items:
+        return "Impossible de récupérer les actualités."
+    items = items[:max_items]
+    msg = f"📰 *Actualités {category}*\n\n"
+    for item in items:
+        msg += f"• *{item['title']}*\n{item['summary']}\n\n"
+    return msg.strip()
+
+# ─── Handlers ─────────────────────────────────────────────────────────────────
+
+async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    profile = load_profile(user_id)
+    nom = profile.get("nom")
+    if nom:
+        msg = f"Content de te retrouver {nom} ! Je suis Konan."
+    else:
+        msg = (
+            "Bonjour ! Je suis Konan, ton orchestrateur d'agents IA.\n\n"
+            "Agents : 💻 Coder · 🔍 Reviewer · 🏗️ Architect · 📊 Analyst\n"
+            "Tu peux m'envoyer du texte, de la voix, des images ou ta localisation.\n\n"
+            "/setnom Prenom — te présenter\n"
+            "/monitoring — état du serveur\n"
+            "/meteo [ville] — météo (ou partage ta position)\n"
+            "/news [tech|politique|football] — actualités\n"
+            "/recherche <query> — recherche web\n"
+            "/note <contenu> — sauvegarder une note\n"
+            "/notes — voir tes notes\n"
+            "/chercher <mot> — chercher dans la mémoire\n"
+            "/briefing 08:00 — briefing matinal automatique\n"
+            "/rappel 30m Message — créer un rappel\n"
+            "/repos — projets Git\n"
+            "/agents — voir les agents\n"
+            "/reset — réinitialiser"
+        )
+    await update.message.reply_text(msg)
+
+async def monitoring_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    status = get_server_status()
+    await update.message.reply_text(status, parse_mode="Markdown")
+
+async def rappel_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    if len(context.args) < 2:
+        await update.message.reply_text(
+            "Usage : /rappel <temps> <message>\n\n"
+            "Exemples :\n"
+            "/rappel 30m Faire une pause\n"
+            "/rappel 2h Revoir l'architecture\n"
+            "/rappel 14:30 Réunion équipe"
+        )
+        return
+
+    time_str = context.args[0]
+    message = " ".join(context.args[1:])
+    target_time = parse_time(time_str)
+
+    if not target_time:
+        await update.message.reply_text("Format de temps non reconnu. Exemples : 30m, 2h, 14:30")
+        return
+
+    reminder_id = f"{user_id}_{int(target_time.timestamp())}"
+    bot = context.application.bot
+
+    scheduler.add_job(
+        send_reminder,
+        "date",
+        run_date=target_time,
+        args=[bot, user_id, message, reminder_id],
+        id=reminder_id
+    )
+
+    reminders = load_reminders()
+    reminders.append({
+        "id": reminder_id,
+        "user_id": user_id,
+        "message": message,
+        "time": target_time.isoformat()
+    })
+    save_reminders(reminders)
+
+    heure = target_time.strftime("%H:%M")
+    await update.message.reply_text(f"⏰ Rappel créé pour {heure} : \"{message}\"")
+
+async def rappels_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    reminders = [r for r in load_reminders() if r["user_id"] == user_id]
+    if not reminders:
+        await update.message.reply_text("Aucun rappel en attente.")
+        return
+    msg = "⏰ Tes rappels :\n\n"
+    for r in reminders:
+        t = datetime.fromisoformat(r["time"]).strftime("%H:%M")
+        msg += f"• {t} — {r['message']}\n"
+    await update.message.reply_text(msg)
+
+async def agents_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    msg = "Agents disponibles :\n\n"
+    for name, cfg in AGENTS.items():
+        emoji = AGENT_EMOJIS.get(name, "🤖")
+        msg += f"{emoji} *{name.capitalize()}* — {cfg['model']}\n"
+    await update.message.reply_text(msg, parse_mode="Markdown")
+
+async def reset(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    for agent in AGENTS:
+        path = memory_path(user_id, agent)
+        if os.path.exists(path):
+            os.remove(path)
+    await update.message.reply_text("Mémoire réinitialisée. Profil conservé.")
+
+async def set_nom(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    if not context.args:
+        await update.message.reply_text("Usage : /setnom Prenom")
+        return
+    nom = " ".join(context.args)
+    profile = load_profile(user_id)
+    profile["nom"] = nom
+    save_profile(user_id, profile)
+    await update.message.reply_text(f"Parfait, je m'en souviendrai {nom} !")
+
+async def set_projet(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    if not context.args:
+        await update.message.reply_text("Usage : /setprojet Nom")
+        return
+    projet = " ".join(context.args)
+    profile = load_profile(user_id)
+    if projet not in profile["projets"]:
+        profile["projets"].append(projet)
+    save_profile(user_id, profile)
+    await update.message.reply_text(f"Projet \"{projet}\" ajouté.")
+
+async def profil(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    profile = load_profile(user_id)
+    nom = profile.get("nom") or "non défini"
+    projets = ", ".join(profile.get("projets", [])) or "aucun"
+    await update.message.reply_text(f"Nom : {nom}\nProjets : {projets}")
+
+async def repos_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    repos = list_repos()
+    if not repos:
+        await update.message.reply_text("Aucun repo cloné.\n/clone <url> pour en ajouter un.")
+        return
+    msg = "Repos disponibles :\n\n"
+    for r in repos:
+        msg += f"📁 {r}\n"
+    msg += "\n/fichiers <repo> · /log <repo> · /analyser <repo> <fichier>"
+    await update.message.reply_text(msg)
+
+async def clone_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    if not context.args:
+        await update.message.reply_text("Usage : /clone <url>")
+        return
+    url = context.args[0]
+    name = url.rstrip("/").split("/")[-1].replace(".git", "")
+    dest = os.path.join(REPOS_DIR, name)
+    await update.message.reply_text(f"Clonage de {name}...")
+    result = subprocess.run(["git", "clone", url, dest], capture_output=True, text=True)
+    if result.returncode == 0:
+        await update.message.reply_text(f"✅ {name} cloné.")
+    else:
+        await update.message.reply_text(f"Erreur : {result.stderr}")
+
+async def fichiers_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    if not context.args:
+        await update.message.reply_text("Usage : /fichiers <repo>")
+        return
+    repo = context.args[0]
+    files = list_files(repo)
+    if files is None:
+        await update.message.reply_text(f"Repo '{repo}' introuvable.")
+        return
+    msg = f"Fichiers de {repo} :\n\n" + "\n".join(files[:50])
+    if len(files) > 50:
+        msg += f"\n... et {len(files) - 50} autres"
+    await update.message.reply_text(msg)
+
+async def log_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    if not context.args:
+        await update.message.reply_text("Usage : /log <repo>")
+        return
+    repo = context.args[0]
+    log = git_log(repo)
+    await update.message.reply_text(f"Derniers commits de {repo} :\n\n{log}" if log else "Repo introuvable.")
+
+async def lire_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    if len(context.args) < 2:
+        await update.message.reply_text("Usage : /lire <repo> <fichier>")
+        return
+    repo = context.args[0]
+    filepath = " ".join(context.args[1:])
+    content = read_file(repo, filepath)
+    if content is None:
+        await update.message.reply_text(f"Fichier '{filepath}' introuvable.")
+        return
+    if len(content) > 3000:
+        content = content[:3000] + "\n... (tronqué)"
+    await update.message.reply_text(f"```\n{content}\n```", parse_mode="Markdown")
+
+async def analyser_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    if len(context.args) < 2:
+        await update.message.reply_text("Usage : /analyser <repo> <fichier>")
+        return
+    repo = context.args[0]
+    filepath = " ".join(context.args[1:])
+    content = read_file(repo, filepath)
+    if content is None:
+        await update.message.reply_text(f"Fichier '{filepath}' introuvable.")
+        return
+    await update.message.reply_text(f"🔍 Analyse de {filepath}...")
+    message = f"Analyse ce fichier et donne résumé, points forts et problèmes :\n\n```\n{content[:4000]}\n```"
+    reply, _ = await ask_agent(user_id, message, "reviewer")
+    await update.message.reply_text(reply)
+
+async def pull_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    if not context.args:
+        await update.message.reply_text("Usage : /pull <repo>")
+        return
+    result = git_pull(context.args[0])
+    await update.message.reply_text(result)
+
+async def google_connect_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    if get_google_creds():
+        await update.message.reply_text("✅ Google déjà connecté.\n/calendrier — voir tes événements\n/mails — voir tes mails")
+        return
+    flow = Flow.from_client_secrets_file(
+        GOOGLE_CREDS_FILE,
+        scopes=GOOGLE_SCOPES,
+        redirect_uri="urn:ietf:wg:oauth:2.0:oob"
+    )
+    auth_url, _ = flow.authorization_url(prompt="consent")
+    pending_oauth[user_id] = flow
+    await update.message.reply_text(
+        "🔗 Connexion Google\n\n"
+        "1. Clique sur ce lien et autorise l'accès :\n"
+        f"{auth_url}\n\n"
+        "2. Copie le code affiché\n"
+        "3. Envoie-le moi avec /google_code LECODICI"
+    )
+
+async def google_code_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    if not context.args:
+        await update.message.reply_text("Usage : /google_code <code>")
+        return
+    if user_id not in pending_oauth:
+        await update.message.reply_text("Lance d'abord /google_connect")
+        return
+    code = context.args[0]
+    flow = pending_oauth[user_id]
+    try:
+        flow.fetch_token(code=code)
+        creds = flow.credentials
+        with open(GOOGLE_TOKEN_FILE, "w") as f:
+            f.write(creds.to_json())
+        del pending_oauth[user_id]
+        await update.message.reply_text(
+            "✅ Google connecté avec succès !\n\n"
+            "/calendrier — voir tes événements\n"
+            "/mails — voir tes mails non lus"
+        )
+    except Exception as e:
+        await update.message.reply_text(f"Erreur d'authentification : {e}")
+
+async def calendrier_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    await update.message.reply_text("📅 Chargement du calendrier...")
+    events = get_calendar_events()
+    if events is None:
+        await update.message.reply_text("Google non connecté. Lance /google_connect")
+        return
+    if not events:
+        await update.message.reply_text("Aucun événement à venir.")
+        return
+    msg = "📅 *Prochains événements :*\n\n"
+    for event in events:
+        start = event["start"].get("dateTime", event["start"].get("date", ""))
+        if "T" in start:
+            dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
+            start_str = dt.strftime("%d/%m %H:%M")
+        else:
+            start_str = start
+        msg += f"• *{event.get('summary', 'Sans titre')}*\n  📅 {start_str}\n\n"
+    await update.message.reply_text(msg, parse_mode="Markdown")
+
+async def mails_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    await update.message.reply_text("📬 Chargement des mails...")
+    emails = get_gmail_messages()
+    if emails is None:
+        await update.message.reply_text("Google non connecté. Lance /google_connect")
+        return
+    if not emails:
+        await update.message.reply_text("Aucun mail non lu.")
+        return
+    msg = "📬 *Mails non lus :*\n\n"
+    for e in emails:
+        msg += f"• *{e['subject']}*\n  De : {e['from'][:50]}\n\n"
+    await update.message.reply_text(msg, parse_mode="Markdown")
+
+async def briefing_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    if not context.args:
+        briefings = load_briefing_config()
+        cfg = briefings.get(str(user_id))
+        if cfg:
+            await update.message.reply_text(
+                f"⏰ Briefing matinal configuré à {cfg['heure']}.\n\n"
+                f"/briefing 07:30 — changer l'heure\n"
+                f"/briefing stop — désactiver\n"
+                f"/briefing_test — recevoir un briefing maintenant"
+            )
+        else:
+            await update.message.reply_text(
+                "Aucun briefing configuré.\n\n"
+                "Usage : /briefing 08:00\n"
+                "/briefing_test — tester maintenant"
+            )
+        return
+
+    if context.args[0].lower() == "stop":
+        briefings = load_briefing_config()
+        briefings.pop(str(user_id), None)
+        save_briefing_config(briefings)
+        job_id = f"briefing_{user_id}"
+        if scheduler.get_job(job_id):
+            scheduler.remove_job(job_id)
+        await update.message.reply_text("Briefing matinal désactivé.")
+        return
+
+    heure = context.args[0]
+    if ":" not in heure:
+        await update.message.reply_text("Format : /briefing 08:00")
+        return
+
+    h, m = heure.split(":")
+    briefings = load_briefing_config()
+    briefings[str(user_id)] = {"heure": heure}
+    save_briefing_config(briefings)
+
+    bot = context.application.bot
+    job_id = f"briefing_{user_id}"
+    if scheduler.get_job(job_id):
+        scheduler.remove_job(job_id)
+    scheduler.add_job(
+        send_briefing,
+        "cron",
+        hour=int(h),
+        minute=int(m),
+        args=[bot, user_id],
+        id=job_id,
+        replace_existing=True
+    )
+    await update.message.reply_text(
+        f"✅ Briefing matinal configuré à {heure}.\n"
+        f"Chaque matin tu recevras : météo, news tech, foot, tes notes et l'état du serveur.\n\n"
+        f"/briefing_test — tester maintenant"
+    )
+
+async def briefing_test_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    await update.message.reply_text("Génération du briefing...")
+    bot = context.application.bot
+    await send_briefing(bot, user_id)
+
+async def note_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    if not context.args:
+        await update.message.reply_text("Usage : /note <contenu>\nEx: /note Je préfère Rust pour le backend")
+        return
+    content = " ".join(context.args)
+    note = add_note(user_id, content)
+    await update.message.reply_text(f"📝 Note #{note['id']} sauvegardée.")
+
+async def notes_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    notes = load_notes(user_id)
+    if not notes:
+        await update.message.reply_text("Aucune note. Utilise /note <contenu> pour en créer une.")
+        return
+    msg = "📝 *Tes notes :*\n\n"
+    for n in notes[-10:]:
+        msg += f"#{n['id']} [{n['date']}]\n{n['content']}\n\n"
+    await update.message.reply_text(msg, parse_mode="Markdown")
+
+async def chercher_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    if not context.args:
+        await update.message.reply_text("Usage : /chercher <mot-clé>")
+        return
+    query = " ".join(context.args)
+    await update.message.reply_text(f"🔍 Recherche dans la mémoire : \"{query}\"...")
+
+    memory_results, note_results = search_memory(user_id, query)
+
+    msg = ""
+
+    if note_results:
+        msg += f"📝 *Notes ({len(note_results)}) :*\n"
+        for n in note_results[:3]:
+            msg += f"• [{n['date']}] {n['content']}\n"
+        msg += "\n"
+
+    if memory_results:
+        msg += f"💬 *Conversations ({len(memory_results)}) :*\n"
+        for r in memory_results[:5]:
+            role_icon = "👤" if r["role"] == "user" else "🤖"
+            msg += f"{role_icon} [{r['agent']}] {r['content'][:200]}\n\n"
+
+    if not msg:
+        msg = f"Aucun résultat pour \"{query}\" dans la mémoire ou les notes."
+
+    await update.message.reply_text(msg, parse_mode="Markdown")
+
+async def meteo_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    profile = load_profile(user_id)
+    city = " ".join(context.args) if context.args else profile.get("ville", DEFAULT_CITY)
+    result = await get_weather(city)
+    await update.message.reply_text(result)
+
+async def recherche_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    if not context.args:
+        await update.message.reply_text("Usage : /recherche <requête>")
+        return
+    query = " ".join(context.args)
+    await update.message.reply_text(f"🔍 Recherche : {query}...")
+    results = web_search(query)
+    await update.message.reply_text(results, parse_mode="Markdown")
+
+async def news_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    category = context.args[0].lower() if context.args else "tech"
+    if category not in NEWS_FEEDS:
+        await update.message.reply_text(f"Catégories disponibles : {', '.join(NEWS_FEEDS.keys())}")
+        return
+    await update.message.reply_text(f"📰 Chargement des news {category}...")
+    news = get_news(category)
+    await update.message.reply_text(news, parse_mode="Markdown")
+
+async def handle_location(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    location = update.message.location
+    lat, lon = location.latitude, location.longitude
+    await update.message.reply_text("📍 Localisation reçue, récupération météo...")
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            response = await client.get(f"https://wttr.in/{lat},{lon}?format=3&lang=fr")
+            result = f"🌤️ {response.text.strip()}"
+        # Sauvegarder la ville dans le profil
+        city_resp = await client.get(f"https://wttr.in/{lat},{lon}?format=%l")
+        city = city_resp.text.strip()
+        profile = load_profile(user_id)
+        profile["ville"] = f"{lat},{lon}"
+        save_profile(user_id, profile)
+        await update.message.reply_text(f"{result}\n\n📍 Position sauvegardée pour les prochaines fois.")
+    except Exception as e:
+        await update.message.reply_text(f"Erreur météo : {e}")
+
+async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    text = update.message.text
+
+    # Détection automatique de note
+    note_triggers = ["note que", "souviens-toi que", "retiens que", "n'oublie pas que", "mémorise que"]
+    text_lower = text.lower()
+    for trigger in note_triggers:
+        if text_lower.startswith(trigger):
+            content = text[len(trigger):].strip()
+            note = add_note(user_id, content)
+            await update.message.reply_text(f"📝 Noté ! (#{note['id']}) : \"{content}\"")
+            return
+
+    await update.message.reply_text("⏳")
+    agent = await detect_agent(text)
+    emoji = AGENT_EMOJIS.get(agent, "🤖")
+    await update.message.reply_text(f"{emoji} Agent {agent.capitalize()}...")
+    reply, _ = await ask_agent(user_id, text, agent)
+    await update.message.reply_text(reply)
+
+async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    user_id = update.effective_user.id
+    await update.message.reply_text("🎤 Transcription...")
+    voice = update.message.voice
+    file = await context.bot.get_file(voice.file_id)
+    with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
+        tmp_path = tmp.name
+    await file.download_to_drive(tmp_path)
+    try:
+        text = await transcribe_voice(tmp_path)
+        await update.message.reply_text(f"« {text} »")
+        agent = await detect_agent(text)
+        emoji = AGENT_EMOJIS.get(agent, "🤖")
+        await update.message.reply_text(f"{emoji} Agent {agent.capitalize()}...")
+        reply, _ = await ask_agent(user_id, text, agent)
+        await update.message.reply_text(reply)
+        audio_path = text_to_audio(reply)
+        with open(audio_path, "rb") as audio_file:
+            await update.message.reply_voice(audio_file)
+        os.unlink(audio_path)
+    except Exception as e:
+        await update.message.reply_text(f"Erreur : {e}")
+    finally:
+        os.unlink(tmp_path)
+
+async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
+    await update.message.reply_text("🖼️ Analyse de l'image...")
+    photo = update.message.photo[-1]
+    file = await context.bot.get_file(photo.file_id)
+    with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
+        tmp_path = tmp.name
+    await file.download_to_drive(tmp_path)
+    try:
+        caption = update.message.caption or "Décris cette image en détail en français."
+        result = await analyze_image(tmp_path, caption)
+        await update.message.reply_text(result)
+    except Exception as e:
+        await update.message.reply_text(f"Erreur : {e}")
+    finally:
+        os.unlink(tmp_path)
+
+# ─── Main ─────────────────────────────────────────────────────────────────────
+
+BRIEFING_FILE = os.path.join(os.path.expanduser("~/agent"), "briefing.json")
+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"
+]
+
+# Stockage temporaire des flows OAuth en attente
+pending_oauth = {}
+
+def load_briefing_config():
+    if os.path.exists(BRIEFING_FILE):
+        with open(BRIEFING_FILE, "r") as f:
+            return json.load(f)
+    return {}
+
+def save_briefing_config(config):
+    with open(BRIEFING_FILE, "w") as f:
+        json.dump(config, f, ensure_ascii=False, indent=2)
+
+async def send_briefing(bot: Bot, user_id: int):
+    try:
+        profile = load_profile(user_id)
+        nom = profile.get("nom", "")
+        ville = profile.get("ville", DEFAULT_CITY)
+        notes = load_notes(user_id)
+        notes_recentes = notes[-3:] if notes else []
+
+        greeting = f"☀️ Bonjour {nom} !\n\n" if nom else "☀️ Bonjour !\n\n"
+        msg = greeting
+
+        # Météo
+        meteo = await get_weather(ville)
+        msg += f"{meteo}\n\n"
+
+        # News tech
+        tech = get_news("tech", max_items=3)
+        msg += f"📰 *Tech du jour :*\n"
+        lines = [l for l in tech.split("\n") if l.startswith("•")][:3]
+        msg += "\n".join(lines) + "\n\n"
+
+        # Football
+        foot = get_news("football", max_items=2)
+        msg += f"⚽ *Football :*\n"
+        lines = [l for l in foot.split("\n") if l.startswith("•")][:2]
+        msg += "\n".join(lines) + "\n\n"
+
+        # Notes récentes
+        if notes_recentes:
+            msg += "📝 *Tes dernières notes :*\n"
+            for n in notes_recentes:
+                msg += f"• {n['content']}\n"
+            msg += "\n"
+
+        # Calendrier
+        events = get_calendar_events(max_results=3)
+        if events:
+            msg += "📅 *Agenda du jour :*\n"
+            for event in events:
+                start = event["start"].get("dateTime", event["start"].get("date", ""))
+                if "T" in start:
+                    dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
+                    start_str = dt.strftime("%H:%M")
+                else:
+                    start_str = start
+                msg += f"• {start_str} — {event.get('summary', 'Sans titre')}\n"
+            msg += "\n"
+
+        # Mails non lus
+        emails = get_gmail_messages(max_results=3)
+        if emails:
+            msg += f"📬 *{len(emails)} mail(s) non lu(s) :*\n"
+            for e in emails[:3]:
+                msg += f"• {e['subject'][:50]}\n"
+            msg += "\n"
+
+        # Monitoring
+        cpu = psutil.cpu_percent(interval=0.5)
+        ram = psutil.virtual_memory()
+        msg += f"🖥️ Serveur : CPU {cpu:.0f}% · RAM {ram.percent:.0f}%"
+
+        await bot.send_message(chat_id=user_id, text=msg, parse_mode="Markdown")
+        logger.info(f"Briefing envoyé à {user_id}")
+    except Exception as e:
+        logger.error(f"Erreur briefing : {e}")
+
+async def post_init(application):
+    scheduler.start()
+    bot = application.bot
+
+    # Recharger les rappels persistés
+    reminders = load_reminders()
+    for r in reminders:
+        target = datetime.fromisoformat(r["time"])
+        if target > datetime.now():
+            scheduler.add_job(
+                send_reminder,
+                "date",
+                run_date=target,
+                args=[bot, r["user_id"], r["message"], r["id"]],
+                id=r["id"],
+                replace_existing=True
+            )
+    logger.info(f"{len(reminders)} rappel(s) rechargé(s).")
+
+    # Recharger les briefings configurés
+    briefings = load_briefing_config()
+    for user_id_str, cfg in briefings.items():
+        user_id = int(user_id_str)
+        heure = cfg.get("heure", "08:00")
+        h, m = heure.split(":")
+        scheduler.add_job(
+            send_briefing,
+            "cron",
+            hour=int(h),
+            minute=int(m),
+            args=[bot, user_id],
+            id=f"briefing_{user_id}",
+            replace_existing=True
+        )
+        logger.info(f"Briefing programmé à {heure} pour {user_id}")
+
+    # Démarrer l'API HTTP pour le client PC
+    api_app = web.Application()
+    api_app.router.add_post("/api/chat", api_chat_handler)
+    runner = web.AppRunner(api_app)
+    await runner.setup()
+    site = web.TCPSite(runner, "0.0.0.0", 8765)
+    await site.start()
+    logger.info("API HTTP démarrée sur port 8765")
+
+async def api_chat_handler(request):
+    try:
+        data = await request.json()
+        text = data.get("text", "").strip()
+        user_id = data.get("user_id", "pc_user")
+        if not text:
+            return web.json_response({"error": "texte vide"}, status=400)
+        agent = await detect_agent(text)
+        response = await ask_agent(user_id, text, agent)
+        return web.json_response({"response": response, "agent": agent})
+    except Exception as e:
+        logger.error(f"API erreur : {e}")
+        return web.json_response({"error": str(e)}, status=500)
+
+if __name__ == "__main__":
+    app = ApplicationBuilder().token(TELEGRAM_TOKEN).post_init(post_init).build()
+    app.add_handler(CommandHandler("start", start))
+    app.add_handler(CommandHandler("reset", reset))
+    app.add_handler(CommandHandler("agents", agents_cmd))
+    app.add_handler(CommandHandler("setnom", set_nom))
+    app.add_handler(CommandHandler("setprojet", set_projet))
+    app.add_handler(CommandHandler("profil", profil))
+    app.add_handler(CommandHandler("monitoring", monitoring_cmd))
+    app.add_handler(CommandHandler("rappel", rappel_cmd))
+    app.add_handler(CommandHandler("rappels", rappels_cmd))
+    app.add_handler(CommandHandler("repos", repos_cmd))
+    app.add_handler(CommandHandler("clone", clone_cmd))
+    app.add_handler(CommandHandler("fichiers", fichiers_cmd))
+    app.add_handler(CommandHandler("lire", lire_cmd))
+    app.add_handler(CommandHandler("analyser", analyser_cmd))
+    app.add_handler(CommandHandler("log", log_cmd))
+    app.add_handler(CommandHandler("pull", pull_cmd))
+    app.add_handler(CommandHandler("briefing", briefing_cmd))
+    app.add_handler(CommandHandler("briefing_test", briefing_test_cmd))
+    app.add_handler(CommandHandler("note", note_cmd))
+    app.add_handler(CommandHandler("notes", notes_cmd))
+    app.add_handler(CommandHandler("chercher", chercher_cmd))
+    app.add_handler(CommandHandler("meteo", meteo_cmd))
+    app.add_handler(CommandHandler("recherche", recherche_cmd))
+    app.add_handler(CommandHandler("news", news_cmd))
+    app.add_handler(CommandHandler("google_connect", google_connect_cmd))
+    app.add_handler(CommandHandler("google_code", google_code_cmd))
+    app.add_handler(CommandHandler("calendrier", calendrier_cmd))
+    app.add_handler(CommandHandler("mails", mails_cmd))
+    app.add_handler(MessageHandler(filters.LOCATION, handle_location))
+    app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
+    app.add_handler(MessageHandler(filters.VOICE, handle_voice))
+    app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
+    logger.info("Konan demarre - Google + Météo + News + Recherche + Monitoring + Rappels + Git + Vision + Voix + Agents.")
+    app.run_polling()

+ 45 - 0
docker-compose.yml

@@ -0,0 +1,45 @@
+services:
+
+  # Serveur LLM local
+  ollama:
+    image: ollama/ollama
+    container_name: konan-ollama
+    volumes:
+      - ollama_data:/root/.ollama
+    ports:
+      - "11434:11434"
+    restart: unless-stopped
+    # Decommenter si GPU Nvidia disponible :
+    # deploy:
+    #   resources:
+    #     reservations:
+    #       devices:
+    #         - driver: nvidia
+    #           count: all
+    #           capabilities: [gpu]
+
+  # Bot Konan
+  konan-bot:
+    build: .
+    container_name: konan-bot
+    depends_on:
+      - ollama
+    env_file:
+      - .env
+    environment:
+      - OLLAMA_URL=http://ollama:11434/api/chat
+      - OLLAMA_GENERATE_URL=http://ollama:11434/api/generate
+    volumes:
+      - ./memory:/app/memory
+      - ./voices:/app/voices
+      - ./repos:/app/repos
+      - ./reminders.json:/app/reminders.json
+      - ./briefing_config.json:/app/briefing_config.json
+      - ./google_credentials.json:/app/google_credentials.json
+      - ./google_token.json:/app/google_token.json
+    ports:
+      - "8765:8765"   # API HTTP pour le client vocal PC
+    restart: unless-stopped
+
+volumes:
+  ollama_data:

+ 70 - 0
install.sh

@@ -0,0 +1,70 @@
+#!/bin/bash
+# install.sh — Installation manuelle de Konan (sans Docker)
+set -e
+
+echo "=== Installation de Konan ==="
+
+# 1. Dependances systeme
+echo "[1/5] Installation des dependances systeme..."
+sudo apt-get update -q
+sudo apt-get install -y ffmpeg libportaudio2 git wget python3 python3-venv alsa-utils
+
+# 2. Environnement Python
+echo "[2/5] Creation de l'environnement Python..."
+python3 -m venv ~/agent/venv
+~/agent/venv/bin/pip install --upgrade pip -q
+~/agent/venv/bin/pip install -r requirements.txt -q
+
+# 3. Voix Piper (masculine francaise)
+echo "[3/5] Telechargement de la voix Piper (fr_FR-tom-medium)..."
+mkdir -p ~/agent/voices
+BASE_URL="https://huggingface.co/rhasspy/piper-voices/resolve/main/fr/fr_FR/tom/medium"
+wget -q --show-progress -O ~/agent/voices/fr_FR-tom-medium.onnx "$BASE_URL/fr_FR-tom-medium.onnx"
+wget -q -O ~/agent/voices/fr_FR-tom-medium.onnx.json "$BASE_URL/fr_FR-tom-medium.onnx.json"
+
+# 4. Ollama
+echo "[4/5] Installation de Ollama..."
+if ! command -v ollama &> /dev/null; then
+    curl -fsSL https://ollama.ai/install.sh | sh
+fi
+
+echo "Telechargement des modeles LLM (peut prendre plusieurs minutes)..."
+ollama pull qwen2.5:3b
+ollama pull qwen2.5-coder:3b
+
+# 5. Service systemd
+echo "[5/5] Configuration du service systemd..."
+sudo tee /etc/systemd/system/agent-bot.service > /dev/null << EOF
+[Unit]
+Description=Konan AI Assistant
+After=network.target ollama.service
+
+[Service]
+Type=simple
+User=$USER
+WorkingDirectory=$HOME/agent
+ExecStart=$HOME/agent/venv/bin/python3 $HOME/agent/bot.py
+Restart=always
+RestartSec=10
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+sudo systemctl daemon-reload
+sudo systemctl enable agent-bot
+sudo systemctl start agent-bot
+
+echo ""
+echo "=== Konan installe avec succes ! ==="
+echo ""
+echo "Commandes utiles :"
+echo "  sudo systemctl status agent-bot   # etat du bot"
+echo "  journalctl -u agent-bot -f        # logs en direct"
+echo "  python3 ~/agent/konan_voice.py    # interface vocale"
+echo ""
+echo "N'oublie pas de copier tes fichiers :"
+echo "  - google_credentials.json -> ~/agent/"
+echo "  - google_token.json       -> ~/agent/ (si deja authentifie)"

+ 93 - 0
konan_client.py

@@ -0,0 +1,93 @@
+"""
+Konan Voice Client — PC Windows
+Push-to-talk : maintiens ESPACE pour parler, relâche pour envoyer.
+Ctrl+C pour quitter.
+
+Installation :
+    pip install sounddevice scipy openai-whisper requests keyboard pyttsx3
+"""
+
+import sounddevice as sd
+import scipy.io.wavfile as wav
+import whisper
+import requests
+import pyttsx3
+import keyboard
+import numpy as np
+import tempfile
+import os
+import sys
+
+KONAN_API = "http://192.168.1.21:8765/api/chat"
+SAMPLE_RATE = 16000
+USER_ID = "pc_user"
+
+print("Chargement de Whisper tiny...")
+model = whisper.load_model("tiny")
+print("Whisper prêt.")
+
+engine = pyttsx3.init()
+engine.setProperty("rate", 165)
+# Voix française si disponible
+voices = engine.getProperty("voices")
+for v in voices:
+    if "fr" in v.id.lower() or "french" in v.name.lower():
+        engine.setProperty("voice", v.id)
+        break
+
+def speak(text):
+    print(f"Konan : {text}")
+    engine.say(text)
+    engine.runAndWait()
+
+def record_until_release():
+    print("🎙️  Enregistrement... (relâche Espace pour envoyer)")
+    chunks = []
+    stream = sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16")
+    stream.start()
+    while keyboard.is_pressed("space"):
+        chunk, _ = stream.read(1024)
+        chunks.append(chunk)
+    stream.stop()
+    stream.close()
+    if not chunks:
+        return None
+    audio = np.concatenate(chunks, axis=0).flatten()
+    return audio
+
+def transcribe(audio):
+    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
+        wav.write(f.name, SAMPLE_RATE, audio)
+        result = model.transcribe(f.name, language="fr")
+        os.unlink(f.name)
+    return result["text"].strip()
+
+def ask_konan(text):
+    try:
+        r = requests.post(KONAN_API, json={"text": text, "user_id": USER_ID}, timeout=120)
+        data = r.json()
+        return data.get("response", "Pas de réponse.")
+    except Exception as e:
+        return f"Erreur connexion : {e}"
+
+speak("Konan est prêt. Maintiens Espace pour parler.")
+print("\n▶  Maintiens ESPACE pour parler. Ctrl+C pour quitter.\n")
+
+try:
+    while True:
+        keyboard.wait("space")
+        audio = record_until_release()
+        if audio is None or len(audio) < SAMPLE_RATE * 0.3:
+            print("(trop court, ignoré)")
+            continue
+        print("Transcription...")
+        text = transcribe(audio)
+        if not text:
+            print("(rien détecté)")
+            continue
+        print(f"Toi : {text}")
+        print("Konan réfléchit...")
+        response = ask_konan(text)
+        speak(response)
+except KeyboardInterrupt:
+    print("\nAu revoir.")

+ 113 - 0
konan_voice.py

@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""
+Konan Voice — interface vocale directe sur le serveur.
+Parlez naturellement, Konan répond à voix haute.
+Ctrl+C pour quitter.
+"""
+import warnings
+warnings.filterwarnings("ignore")
+import logging
+logging.getLogger("whisper").setLevel(logging.ERROR)
+
+import sounddevice as sd
+import numpy as np
+import whisper
+import requests
+import subprocess
+import tempfile
+import os
+import scipy.io.wavfile as wav
+
+SAMPLE_RATE    = 16000
+CHUNK_SIZE     = int(SAMPLE_RATE * 0.3)   # 300 ms par chunk
+SILENCE_THRESH = 1200                      # seuil énergie RMS (calibré au bruit ambiant)
+SILENCE_DUR    = 1.5                       # secondes de silence pour arrêter
+MIN_SPEECH_SEC = 0.5                       # durée min pour traiter
+
+KONAN_API  = "http://localhost:8765/api/chat"
+USER_ID    = "voice_local"
+PIPER_BIN  = os.path.expanduser("~/agent/venv/bin/piper")
+VOICE_MODEL = os.path.expanduser("~/agent/voices/fr_FR-upmc-medium.onnx")
+
+def rms(chunk):
+    return np.sqrt(np.mean(chunk.astype(np.float32) ** 2))
+
+def speak(text):
+    print(f"\nKonan : {text}\n")
+    try:
+        proc = subprocess.Popen(
+            [PIPER_BIN, "--model", VOICE_MODEL, "--output-raw"],
+            stdin=subprocess.PIPE,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.DEVNULL,
+        )
+        raw, _ = proc.communicate(input=text.encode())
+        audio = np.frombuffer(raw, dtype=np.int16)
+        sd.play(audio, samplerate=22050)
+        sd.wait()
+    except Exception as e:
+        print(f"[TTS erreur] {e}")
+
+def record_utterance():
+    silence_chunks_needed = int(SILENCE_DUR / 0.3)
+    recorded = []
+    silence_count = 0
+    speech_started = False
+
+    with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16") as stream:
+        while True:
+            chunk, _ = stream.read(CHUNK_SIZE)
+            energy = rms(chunk)
+            if energy > SILENCE_THRESH:
+                if not speech_started:
+                    print("🎙️  Écoute...")
+                    speech_started = True
+                recorded.append(chunk.copy())
+                silence_count = 0
+            elif speech_started:
+                recorded.append(chunk.copy())
+                silence_count += 1
+                if silence_count >= silence_chunks_needed:
+                    break
+
+    if not speech_started or len(recorded) < int(MIN_SPEECH_SEC / 0.3):
+        return None
+    return np.concatenate(recorded, axis=0).flatten()
+
+def transcribe(audio):
+    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
+        wav.write(f.name, SAMPLE_RATE, audio)
+        result = model.transcribe(f.name, language="fr")
+        os.unlink(f.name)
+    return result["text"].strip()
+
+def ask_konan(text):
+    try:
+        r = requests.post(KONAN_API, json={"text": text, "user_id": USER_ID}, timeout=120)
+        return r.json().get("response", "Pas de réponse.")
+    except Exception as e:
+        return f"Erreur connexion au bot : {e}"
+
+print("Chargement de Whisper tiny...")
+model = whisper.load_model("tiny")
+print("Whisper prêt.\n")
+
+speak("Konan est prêt. Je vous écoute.")
+print("▶  Parlez naturellement. Ctrl+C pour quitter.\n")
+
+try:
+    while True:
+        audio = record_utterance()
+        if audio is None:
+            continue
+        print("Transcription...")
+        text = transcribe(audio)
+        if not text:
+            continue
+        print(f"Vous : {text}")
+        print("Konan réfléchit...")
+        response = ask_konan(text)
+        speak(response)
+except KeyboardInterrupt:
+    speak("À bientôt.")
+    print("\nAu revoir.")

+ 16 - 0
requirements.txt

@@ -0,0 +1,16 @@
+python-telegram-bot>=20.0
+openai-whisper
+httpx
+psutil
+feedparser
+duckduckgo-search
+apscheduler
+google-api-python-client
+google-auth-httplib2
+google-auth-oauthlib
+sounddevice
+scipy
+aiohttp
+requests
+numpy
+piper-tts