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" # ─── Détection d'intention (tool calling) ───────────────────────────────────── import re as _re async def detect_intent(text: str) -> dict: prompt = ( "Analyse ce message en français et réponds UNIQUEMENT avec un objet JSON sur une seule ligne.\n\n" "Intents possibles :\n" '{"intent":"meteo","city":"NomVille"} — météo (city=null si non précisé)\n' '{"intent":"news","category":"tech"} — actualités (tech/sport/politique/general)\n' '{"intent":"recherche","query":"..."} — recherche web\n' '{"intent":"monitoring"} — état serveur CPU/RAM/disque\n' '{"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":"conversation"} — tout le reste\n\n' f'Message : "{text}"\n\nJSON :' ) try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.post(OLLAMA_URL, json={ "model": "qwen2.5:3b", "messages": [{"role": "user", "content": prompt}], "stream": False, "options": {"num_predict": 60, "temperature": 0} }) raw = resp.json()["message"]["content"].strip() match = _re.search(r'\{[^}]+\}', raw) if match: return json.loads(match.group()) except Exception: pass return {"intent": "conversation"} async def execute_intent(intent_data: dict, user_id: int, update, context) -> bool: """Exécute l'intention détectée. Retourne True si traitée, False pour conversation.""" intent = intent_data.get("intent", "conversation") if intent == "meteo": city = intent_data.get("city") or DEFAULT_CITY result = await get_weather(city) await update.message.reply_text(result) return True elif intent == "news": category = intent_data.get("category", "tech") if category not in NEWS_FEEDS: category = "tech" result = get_news(category) await update.message.reply_text(result, parse_mode="Markdown") return True elif intent == "recherche": query = intent_data.get("query", "") if not query: return False result = web_search(query) await update.message.reply_text(result, parse_mode="Markdown") return True elif intent == "monitoring": status = get_server_status() await update.message.reply_text(status, parse_mode="Markdown") return True elif intent == "calendrier": events = get_calendar_events() if events is None: await update.message.reply_text("Google non connecté. Lance /google_connect") elif not events: await update.message.reply_text("Aucun événement à venir.") else: 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") return True elif intent == "mails": emails = get_gmail_messages() if emails is None: await update.message.reply_text("Google non connecté. Lance /google_connect") elif not emails: await update.message.reply_text("Aucun mail non lu.") else: 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") return True elif intent == "rappel": time_str = intent_data.get("time", "") message = intent_data.get("message", "") if not time_str or not message: await update.message.reply_text("Précise l'heure et le message du rappel.") return True target_time = parse_time(time_str) if not target_time: await update.message.reply_text(f"Format de temps non reconnu : {time_str}") return True 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, replace_existing=True ) reminders = load_reminders() reminders.append({"id": reminder_id, "user_id": user_id, "message": message, "time": target_time.isoformat()}) save_reminders(reminders) await update.message.reply_text(f"⏰ Rappel créé : \"{message}\" à {target_time.strftime('%H:%M le %d/%m')}") return True return False # ─── 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 — recherche web\n" "/note — sauvegarder une note\n" "/notes — voir tes notes\n" "/chercher — 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 \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 pour en ajouter un.") return msg = "Repos disponibles :\n\n" for r in repos: msg += f"📁 {r}\n" msg += "\n/fichiers · /log · /analyser " 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 ") 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 ") 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 ") 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 ") 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 ") 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 ") 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 ") 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 \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 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 ") 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 ") 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("⏳") intent_data = await detect_intent(text) if await execute_intent(intent_data, user_id, update, context): return 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} »") intent_data = await detect_intent(text) if not await execute_intent(intent_data, user_id, update, context): 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()