| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450 |
- 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":"repondre_mail","expediteur":"nom ou email","consigne":"ce qu il faut dire"} — répondre à un email\n'
- '{"intent":"conversation"} — tout le reste\n\n'
- f'Message : "{text}"\n\nJSON :'
- )
- 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
- elif intent == "repondre_mail":
- expediteur = intent_data.get("expediteur", "")
- consigne = intent_data.get("consigne", "")
- emails = get_gmail_messages(max_results=10)
- if emails is None:
- await update.message.reply_text("Google non connecté. Lance /google_connect")
- return True
- # Trouver l'email correspondant
- email_found = None
- for e in emails:
- if expediteur.lower() in e["from"].lower() or expediteur.lower() in e["subject"].lower():
- email_found = e
- break
- if not email_found:
- await update.message.reply_text(f"Je n'ai pas trouvé d'email de \"{expediteur}\" dans tes non-lus.")
- return True
- # Récupérer le corps
- await update.message.reply_text(f"📧 Email trouvé de {email_found['from']}\nSujet : {email_found['subject']}\n\nRédaction de la réponse...")
- body = get_email_body(email_found["id"])
- prompt = (
- f"Tu dois rédiger une réponse à cet email en français.\n\n"
- f"De : {email_found['from']}\n"
- f"Sujet : {email_found['subject']}\n"
- f"Contenu : {body}\n\n"
- f"Consigne : {consigne}\n\n"
- "Rédige uniquement le corps de la réponse, sans salutation excessive, de façon professionnelle et concise."
- )
- reply, _ = await ask_agent(user_id, prompt, "konan")
- # Stocker en attente de confirmation
- pending_replies[user_id] = {
- "to": email_found["from"],
- "subject": email_found["subject"],
- "thread_id": email_found["thread_id"],
- "message_id": email_found["message_id"],
- "draft": reply
- }
- await update.message.reply_text(
- f"📝 *Voici ma réponse proposée :*\n\n{reply}\n\n"
- "Réponds *oui* pour envoyer, *non* pour annuler.",
- parse_mode="Markdown"
- )
- return True
- return False
- # ─── Ollama ───────────────────────────────────────────────────────────────────
- 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({
- "id": msg["id"],
- "thread_id": detail.get("threadId", ""),
- "message_id": headers.get("Message-ID", ""),
- "from": headers.get("From", ""),
- "subject": headers.get("Subject", ""),
- "date": headers.get("Date", "")
- })
- return emails
- def get_email_body(msg_id: str) -> str:
- creds = get_google_creds()
- if not creds:
- return ""
- try:
- service = build("gmail", "v1", credentials=creds)
- detail = service.users().messages().get(userId="me", id=msg_id, format="full").execute()
- parts = detail.get("payload", {}).get("parts", [])
- for part in parts:
- if part.get("mimeType") == "text/plain":
- data = part["body"].get("data", "")
- import base64 as b64
- return b64.urlsafe_b64decode(data + "==").decode("utf-8", errors="ignore")[:1500]
- # Pas de parts — corps direct
- body_data = detail.get("payload", {}).get("body", {}).get("data", "")
- if body_data:
- import base64 as b64
- return b64.urlsafe_b64decode(body_data + "==").decode("utf-8", errors="ignore")[:1500]
- except Exception:
- pass
- return ""
- def send_email_reply(to: str, subject: str, body: str, thread_id: str, in_reply_to: str) -> bool:
- import email.mime.text
- import base64 as b64
- creds = get_google_creds()
- if not creds:
- return False
- try:
- service = build("gmail", "v1", credentials=creds)
- msg = email.mime.text.MIMEText(body)
- msg["To"] = to
- msg["Subject"] = subject if subject.startswith("Re:") else f"Re: {subject}"
- if in_reply_to:
- msg["In-Reply-To"] = in_reply_to
- msg["References"] = in_reply_to
- raw = b64.urlsafe_b64encode(msg.as_bytes()).decode()
- service.users().messages().send(
- userId="me",
- body={"raw": raw, "threadId": thread_id}
- ).execute()
- return True
- except Exception as e:
- logger.error(f"Erreur envoi email : {e}")
- return False
- # En attente de confirmation d'envoi d'email
- pending_replies: dict = {}
- # ─── Notes ────────────────────────────────────────────────────────────────────
- def notes_path(user_id):
- 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
- # Confirmation d'envoi d'email en attente
- if user_id in pending_replies:
- if text_lower.strip() in ["oui", "yes", "envoie", "ok", "confirme"]:
- pr = pending_replies.pop(user_id)
- ok = send_email_reply(pr["to"], pr["subject"], pr["draft"], pr["thread_id"], pr["message_id"])
- if ok:
- await update.message.reply_text("✅ Email envoyé !")
- else:
- await update.message.reply_text("Erreur lors de l'envoi. Vérifie la connexion Google.")
- return
- elif text_lower.strip() in ["non", "no", "annule", "cancel"]:
- pending_replies.pop(user_id)
- await update.message.reply_text("❌ Email annulé.")
- return
- await update.message.reply_text("⏳")
- intent_data = await detect_intent(text)
- if await execute_intent(intent_data, user_id, update, context):
- 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",
- "https://www.googleapis.com/auth/gmail.send"
- ]
- # 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()
|