bot.py 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450
  1. import logging
  2. import json
  3. import os
  4. import tempfile
  5. import subprocess
  6. import base64
  7. import httpx
  8. import whisper
  9. import psutil
  10. import feedparser
  11. from datetime import datetime, timedelta
  12. from apscheduler.schedulers.asyncio import AsyncIOScheduler
  13. from duckduckgo_search import DDGS
  14. from telegram import Update, Bot
  15. from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, filters, ContextTypes
  16. from google.oauth2.credentials import Credentials
  17. from google_auth_oauthlib.flow import Flow
  18. from googleapiclient.discovery import build
  19. from google.auth.transport.requests import Request
  20. from aiohttp import web
  21. TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN", "")
  22. if not TELEGRAM_TOKEN:
  23. raise RuntimeError("TELEGRAM_TOKEN non defini. Ajoute-le dans .env ou en variable d'environnement.")
  24. OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/chat")
  25. OLLAMA_GENERATE_URL = os.environ.get("OLLAMA_GENERATE_URL", "http://localhost:11434/api/generate")
  26. MEMORY_DIR = os.path.expanduser("~/agent/memory")
  27. REPOS_DIR = os.path.expanduser("~/agent/repos")
  28. VOICE_MODEL = os.path.expanduser("~/agent/voices/fr_FR-upmc-medium.onnx")
  29. PIPER_BIN = os.path.expanduser("~/agent/venv/bin/piper")
  30. REMINDERS_FILE = os.path.join(os.path.expanduser("~/agent"), "reminders.json")
  31. DEFAULT_CITY = "Nantes"
  32. NEWS_FEEDS = {
  33. "tech": [
  34. "https://www.01net.com/rss/",
  35. "https://www.lemonde.fr/pixels/rss_full.xml",
  36. "https://www.zdnet.fr/feeds/rss/actualites/",
  37. ],
  38. "politique": [
  39. "https://www.lemonde.fr/politique/rss_full.xml",
  40. "https://www.rfi.fr/fr/rss/politique-fr/",
  41. "https://www.francetvinfo.fr/politique.rss",
  42. ],
  43. "football": [
  44. "https://www.lequipe.fr/rss/actu_rss_Football.xml",
  45. "https://www.bbc.co.uk/sport/football/european/rss.xml",
  46. ]
  47. }
  48. logging.basicConfig(level=logging.INFO)
  49. logger = logging.getLogger(__name__)
  50. os.makedirs(MEMORY_DIR, exist_ok=True)
  51. os.makedirs(REPOS_DIR, exist_ok=True)
  52. scheduler = AsyncIOScheduler()
  53. # ─── Agents ───────────────────────────────────────────────────────────────────
  54. AGENTS = {
  55. "konan": {
  56. "model": "qwen2.5:3b",
  57. "prompt": (
  58. "Tu es Konan, orchestrateur d'agents IA et développeur senior. "
  59. "Tu adaptes ton ton naturellement selon le contexte : "
  60. "décontracté et amical pour une conversation, précis et direct pour du technique, "
  61. "structuré et rigoureux pour de l'architecture, encourageant pour du brainstorming. "
  62. "Tu parles toujours en français. Tu ne te présentes pas à chaque message. "
  63. "Quand une demande nécessite un expert spécialisé, tu le dis et tu délègues. "
  64. "Tu es concis : pas de blabla inutile, tu vas droit au but."
  65. )
  66. },
  67. "coder": {
  68. "model": "qwen2.5-coder:3b",
  69. "prompt": (
  70. "Tu es l'agent Coder de Konan, expert en développement logiciel. "
  71. "Tu écris du code propre, lisible et bien commenté. "
  72. "Tu expliques tes choix techniques brièvement. "
  73. "Tu signales les bugs potentiels et proposes des améliorations. "
  74. "Tu réponds en français, avec le code dans des blocs formatés."
  75. )
  76. },
  77. "reviewer": {
  78. "model": "qwen2.5-coder:3b",
  79. "prompt": (
  80. "Tu es l'agent Reviewer de Konan, expert en revue de code. "
  81. "Tu analyses le code de façon critique et constructive. "
  82. "Tu identifies : bugs, failles de sécurité, mauvaises pratiques, performance. "
  83. "Tu proposes des corrections concrètes avec des exemples. "
  84. "Sois direct et précis. Tu réponds en français."
  85. )
  86. },
  87. "architect": {
  88. "model": "qwen2.5:3b",
  89. "prompt": (
  90. "Tu es l'agent Architect de Konan, expert en architecture logicielle et systèmes IA. "
  91. "Tu penses en termes de scalabilité, maintenabilité et simplicité. "
  92. "Tu proposes des architectures claires avec des schémas textuels si nécessaire. "
  93. "Tu justifies tes choix et présentes les alternatives. "
  94. "Tu réponds en français de façon structurée."
  95. )
  96. },
  97. "analyst": {
  98. "model": "qwen2.5:3b",
  99. "prompt": (
  100. "Tu es l'agent Analyst de Konan, expert en analyse et documentation. "
  101. "Tu synthétises l'information de façon claire et structurée. "
  102. "Tu produis des analyses, des résumés, de la documentation technique. "
  103. "Tu es méthodique et exhaustif sans être verbeux. Tu réponds en français."
  104. )
  105. }
  106. }
  107. AGENT_EMOJIS = {"konan": "🧠", "coder": "💻", "reviewer": "🔍", "architect": "🏗️", "analyst": "📊"}
  108. # ─── Profil ───────────────────────────────────────────────────────────────────
  109. def profile_path(user_id):
  110. return os.path.join(MEMORY_DIR, f"{user_id}_profile.json")
  111. def load_profile(user_id):
  112. path = profile_path(user_id)
  113. if os.path.exists(path):
  114. with open(path, "r") as f:
  115. return json.load(f)
  116. return {"nom": None, "projets": [], "preferences": ""}
  117. def save_profile(user_id, profile):
  118. with open(profile_path(user_id), "w") as f:
  119. json.dump(profile, f, ensure_ascii=False, indent=2)
  120. # ─── Mémoire ──────────────────────────────────────────────────────────────────
  121. def memory_path(user_id, agent="konan"):
  122. return os.path.join(MEMORY_DIR, f"{user_id}_{agent}.json")
  123. def build_system_prompt(user_id, agent="konan"):
  124. profile = load_profile(user_id)
  125. nom = profile.get("nom")
  126. projets = profile.get("projets", [])
  127. base = AGENTS[agent]["prompt"]
  128. if nom:
  129. base += f" Tu parles à {nom}."
  130. if projets:
  131. base += f" Ses projets : {', '.join(projets)}."
  132. return base
  133. def load_conversation(user_id, agent="konan"):
  134. path = memory_path(user_id, agent)
  135. system = build_system_prompt(user_id, agent)
  136. if os.path.exists(path):
  137. msgs = json.load(open(path))
  138. msgs[0] = {"role": "system", "content": system}
  139. return msgs
  140. return [{"role": "system", "content": system}]
  141. def save_conversation(user_id, messages, agent="konan"):
  142. with open(memory_path(user_id, agent), "w") as f:
  143. json.dump(messages, f, ensure_ascii=False, indent=2)
  144. # ─── Monitoring ───────────────────────────────────────────────────────────────
  145. def get_server_status():
  146. cpu = psutil.cpu_percent(interval=1)
  147. ram = psutil.virtual_memory()
  148. disk = psutil.disk_usage("/")
  149. temps = psutil.sensors_temperatures() if hasattr(psutil, "sensors_temperatures") else {}
  150. ram_used = ram.used / (1024**3)
  151. ram_total = ram.total / (1024**3)
  152. disk_used = disk.used / (1024**3)
  153. disk_total = disk.total / (1024**3)
  154. cpu_emoji = "🔴" if cpu > 80 else "🟡" if cpu > 50 else "🟢"
  155. ram_emoji = "🔴" if ram.percent > 80 else "🟡" if ram.percent > 60 else "🟢"
  156. disk_emoji = "🔴" if disk.percent > 90 else "🟡" if disk.percent > 75 else "🟢"
  157. msg = f"📊 *État du serveur*\n\n"
  158. msg += f"{cpu_emoji} CPU : {cpu:.1f}%\n"
  159. msg += f"{ram_emoji} RAM : {ram_used:.1f}/{ram_total:.1f} GB ({ram.percent}%)\n"
  160. msg += f"{disk_emoji} Disque : {disk_used:.1f}/{disk_total:.1f} GB ({disk.percent}%)\n"
  161. # Processus les plus gourmands
  162. procs = sorted(psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]),
  163. key=lambda p: p.info["cpu_percent"] or 0, reverse=True)[:3]
  164. if procs:
  165. msg += "\n🔝 *Top processus CPU :*\n"
  166. for p in procs:
  167. msg += f" {p.info['name']} — CPU {p.info['cpu_percent']:.1f}% RAM {p.info['memory_percent']:.1f}%\n"
  168. # Ollama actif ?
  169. ollama_running = any("ollama" in p.info["name"].lower() for p in psutil.process_iter(["name"]))
  170. msg += f"\n🤖 Ollama : {'✅ actif' if ollama_running else '❌ inactif'}\n"
  171. msg += f"🕐 Uptime : {datetime.now().strftime('%H:%M:%S')}"
  172. return msg
  173. # ─── Rappels ──────────────────────────────────────────────────────────────────
  174. def load_reminders():
  175. if os.path.exists(REMINDERS_FILE):
  176. with open(REMINDERS_FILE, "r") as f:
  177. return json.load(f)
  178. return []
  179. def save_reminders(reminders):
  180. with open(REMINDERS_FILE, "w") as f:
  181. json.dump(reminders, f, ensure_ascii=False, indent=2)
  182. async def send_reminder(bot: Bot, user_id: int, message: str, reminder_id: str):
  183. try:
  184. await bot.send_message(chat_id=user_id, text=f"⏰ Rappel : {message}")
  185. # Supprimer le rappel après envoi
  186. reminders = load_reminders()
  187. reminders = [r for r in reminders if r["id"] != reminder_id]
  188. save_reminders(reminders)
  189. except Exception as e:
  190. logger.error(f"Erreur rappel : {e}")
  191. def parse_time(time_str: str):
  192. """Parse '30m', '2h', '10:30' ou 'demain 9h'"""
  193. now = datetime.now()
  194. time_str = time_str.strip().lower()
  195. if time_str.endswith("m"):
  196. minutes = int(time_str[:-1])
  197. return now + timedelta(minutes=minutes)
  198. elif time_str.endswith("h") and ":" not in time_str:
  199. hours = int(time_str[:-1])
  200. return now + timedelta(hours=hours)
  201. elif ":" in time_str:
  202. h, m = time_str.split(":")
  203. target = now.replace(hour=int(h), minute=int(m), second=0)
  204. if target < now:
  205. target += timedelta(days=1)
  206. return target
  207. return None
  208. # ─── Détection d'intention ────────────────────────────────────────────────────
  209. async def detect_agent(message: str) -> str:
  210. prompt = (
  211. "Analyse cette demande et réponds UNIQUEMENT par un de ces mots : "
  212. "konan, coder, reviewer, architect, analyst.\n\n"
  213. "- konan : conversation, question générale\n"
  214. "- coder : écrire du code, corriger un bug\n"
  215. "- reviewer : relire du code, code review\n"
  216. "- architect : architecture, conception, design\n"
  217. "- analyst : analyser, documenter, résumer\n\n"
  218. f"Demande : {message}\n\nRéponds avec un seul mot."
  219. )
  220. try:
  221. async with httpx.AsyncClient(timeout=30) as client:
  222. response = await client.post(OLLAMA_URL, json={
  223. "model": "qwen2.5:3b",
  224. "messages": [{"role": "user", "content": prompt}],
  225. "stream": False,
  226. "options": {"num_predict": 5}
  227. })
  228. result = response.json()["message"]["content"].strip().lower()
  229. for agent in AGENTS:
  230. if agent in result:
  231. return agent
  232. except Exception:
  233. pass
  234. return "konan"
  235. # ─── Détection d'intention (tool calling) ─────────────────────────────────────
  236. import re as _re
  237. async def detect_intent(text: str) -> dict:
  238. prompt = (
  239. "Analyse ce message en français et réponds UNIQUEMENT avec un objet JSON sur une seule ligne.\n\n"
  240. "Intents possibles :\n"
  241. '{"intent":"meteo","city":"NomVille"} — météo (city=null si non précisé)\n'
  242. '{"intent":"news","category":"tech"} — actualités (tech/sport/politique/general)\n'
  243. '{"intent":"recherche","query":"..."} — recherche web\n'
  244. '{"intent":"monitoring"} — état serveur CPU/RAM/disque\n'
  245. '{"intent":"calendrier"} — agenda Google Calendar\n'
  246. '{"intent":"mails"} — mails Gmail non lus\n'
  247. '{"intent":"rappel","time":"30m","message":"texte"} — créer un rappel\n'
  248. '{"intent":"repondre_mail","expediteur":"nom ou email","consigne":"ce qu il faut dire"} — répondre à un email\n'
  249. '{"intent":"conversation"} — tout le reste\n\n'
  250. f'Message : "{text}"\n\nJSON :'
  251. )
  252. try:
  253. async with httpx.AsyncClient(timeout=15) as client:
  254. resp = await client.post(OLLAMA_URL, json={
  255. "model": "qwen2.5:3b",
  256. "messages": [{"role": "user", "content": prompt}],
  257. "stream": False,
  258. "options": {"num_predict": 60, "temperature": 0}
  259. })
  260. raw = resp.json()["message"]["content"].strip()
  261. match = _re.search(r'\{[^}]+\}', raw)
  262. if match:
  263. return json.loads(match.group())
  264. except Exception:
  265. pass
  266. return {"intent": "conversation"}
  267. async def execute_intent(intent_data: dict, user_id: int, update, context) -> bool:
  268. """Exécute l'intention détectée. Retourne True si traitée, False pour conversation."""
  269. intent = intent_data.get("intent", "conversation")
  270. if intent == "meteo":
  271. city = intent_data.get("city") or DEFAULT_CITY
  272. result = await get_weather(city)
  273. await update.message.reply_text(result)
  274. return True
  275. elif intent == "news":
  276. category = intent_data.get("category", "tech")
  277. if category not in NEWS_FEEDS:
  278. category = "tech"
  279. result = get_news(category)
  280. await update.message.reply_text(result, parse_mode="Markdown")
  281. return True
  282. elif intent == "recherche":
  283. query = intent_data.get("query", "")
  284. if not query:
  285. return False
  286. result = web_search(query)
  287. await update.message.reply_text(result, parse_mode="Markdown")
  288. return True
  289. elif intent == "monitoring":
  290. status = get_server_status()
  291. await update.message.reply_text(status, parse_mode="Markdown")
  292. return True
  293. elif intent == "calendrier":
  294. events = get_calendar_events()
  295. if events is None:
  296. await update.message.reply_text("Google non connecté. Lance /google_connect")
  297. elif not events:
  298. await update.message.reply_text("Aucun événement à venir.")
  299. else:
  300. msg = "📅 *Prochains événements :*\n\n"
  301. for event in events:
  302. start = event["start"].get("dateTime", event["start"].get("date", ""))
  303. if "T" in start:
  304. dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
  305. start_str = dt.strftime("%d/%m %H:%M")
  306. else:
  307. start_str = start
  308. msg += f"• *{event.get('summary', 'Sans titre')}*\n 📅 {start_str}\n\n"
  309. await update.message.reply_text(msg, parse_mode="Markdown")
  310. return True
  311. elif intent == "mails":
  312. emails = get_gmail_messages()
  313. if emails is None:
  314. await update.message.reply_text("Google non connecté. Lance /google_connect")
  315. elif not emails:
  316. await update.message.reply_text("Aucun mail non lu.")
  317. else:
  318. msg = "📬 *Mails non lus :*\n\n"
  319. for e in emails:
  320. msg += f"• *{e['subject']}*\n De : {e['from'][:50]}\n\n"
  321. await update.message.reply_text(msg, parse_mode="Markdown")
  322. return True
  323. elif intent == "rappel":
  324. time_str = intent_data.get("time", "")
  325. message = intent_data.get("message", "")
  326. if not time_str or not message:
  327. await update.message.reply_text("Précise l'heure et le message du rappel.")
  328. return True
  329. target_time = parse_time(time_str)
  330. if not target_time:
  331. await update.message.reply_text(f"Format de temps non reconnu : {time_str}")
  332. return True
  333. reminder_id = f"{user_id}_{int(target_time.timestamp())}"
  334. bot = context.application.bot
  335. scheduler.add_job(
  336. send_reminder, "date",
  337. run_date=target_time,
  338. args=[bot, user_id, message, reminder_id],
  339. id=reminder_id, replace_existing=True
  340. )
  341. reminders = load_reminders()
  342. reminders.append({"id": reminder_id, "user_id": user_id, "message": message, "time": target_time.isoformat()})
  343. save_reminders(reminders)
  344. await update.message.reply_text(f"⏰ Rappel créé : \"{message}\" à {target_time.strftime('%H:%M le %d/%m')}")
  345. return True
  346. elif intent == "repondre_mail":
  347. expediteur = intent_data.get("expediteur", "")
  348. consigne = intent_data.get("consigne", "")
  349. emails = get_gmail_messages(max_results=10)
  350. if emails is None:
  351. await update.message.reply_text("Google non connecté. Lance /google_connect")
  352. return True
  353. # Trouver l'email correspondant
  354. email_found = None
  355. for e in emails:
  356. if expediteur.lower() in e["from"].lower() or expediteur.lower() in e["subject"].lower():
  357. email_found = e
  358. break
  359. if not email_found:
  360. await update.message.reply_text(f"Je n'ai pas trouvé d'email de \"{expediteur}\" dans tes non-lus.")
  361. return True
  362. # Récupérer le corps
  363. await update.message.reply_text(f"📧 Email trouvé de {email_found['from']}\nSujet : {email_found['subject']}\n\nRédaction de la réponse...")
  364. body = get_email_body(email_found["id"])
  365. prompt = (
  366. f"Tu dois rédiger une réponse à cet email en français.\n\n"
  367. f"De : {email_found['from']}\n"
  368. f"Sujet : {email_found['subject']}\n"
  369. f"Contenu : {body}\n\n"
  370. f"Consigne : {consigne}\n\n"
  371. "Rédige uniquement le corps de la réponse, sans salutation excessive, de façon professionnelle et concise."
  372. )
  373. reply, _ = await ask_agent(user_id, prompt, "konan")
  374. # Stocker en attente de confirmation
  375. pending_replies[user_id] = {
  376. "to": email_found["from"],
  377. "subject": email_found["subject"],
  378. "thread_id": email_found["thread_id"],
  379. "message_id": email_found["message_id"],
  380. "draft": reply
  381. }
  382. await update.message.reply_text(
  383. f"📝 *Voici ma réponse proposée :*\n\n{reply}\n\n"
  384. "Réponds *oui* pour envoyer, *non* pour annuler.",
  385. parse_mode="Markdown"
  386. )
  387. return True
  388. return False
  389. # ─── Ollama ───────────────────────────────────────────────────────────────────
  390. async def ask_agent(user_id, message, agent="konan"):
  391. messages = load_conversation(user_id, agent)
  392. messages.append({"role": "user", "content": message})
  393. if len(messages) > 21:
  394. messages = [messages[0]] + messages[-20:]
  395. try:
  396. async with httpx.AsyncClient(timeout=180) as client:
  397. response = await client.post(OLLAMA_URL, json={
  398. "model": AGENTS[agent]["model"],
  399. "messages": messages,
  400. "stream": False
  401. })
  402. data = response.json()
  403. reply = data["message"]["content"]
  404. messages.append({"role": "assistant", "content": reply})
  405. save_conversation(user_id, messages, agent)
  406. return reply, agent
  407. except Exception as e:
  408. return f"Erreur : {e}", agent
  409. # ─── Vision ───────────────────────────────────────────────────────────────────
  410. async def analyze_image(image_path: str, question: str = "Décris cette image en détail en français.") -> str:
  411. with open(image_path, "rb") as f:
  412. image_b64 = base64.b64encode(f.read()).decode("utf-8")
  413. try:
  414. async with httpx.AsyncClient(timeout=180) as client:
  415. response = await client.post(OLLAMA_GENERATE_URL, json={
  416. "model": "moondream",
  417. "prompt": question,
  418. "images": [image_b64],
  419. "stream": False
  420. })
  421. return response.json().get("response", "Impossible d'analyser l'image.")
  422. except Exception as e:
  423. return f"Erreur analyse image : {e}"
  424. # ─── TTS / STT ────────────────────────────────────────────────────────────────
  425. logger.info("Chargement Whisper...")
  426. whisper_model = whisper.load_model("tiny")
  427. logger.info("Whisper pret.")
  428. def text_to_audio(text):
  429. tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
  430. tmp.close()
  431. subprocess.run(
  432. [PIPER_BIN, "--model", VOICE_MODEL, "--output_file", tmp.name],
  433. input=text.encode("utf-8"), check=True
  434. )
  435. return tmp.name
  436. async def transcribe_voice(file_path):
  437. result = whisper_model.transcribe(file_path, language="fr")
  438. return result["text"].strip()
  439. # ─── Git ──────────────────────────────────────────────────────────────────────
  440. def list_repos():
  441. if not os.path.exists(REPOS_DIR):
  442. return []
  443. return [d for d in os.listdir(REPOS_DIR) if os.path.isdir(os.path.join(REPOS_DIR, d, ".git"))]
  444. def list_files(repo):
  445. repo_path = os.path.join(REPOS_DIR, repo)
  446. if not os.path.exists(repo_path):
  447. return None
  448. result = subprocess.run(["git", "ls-files"], cwd=repo_path, capture_output=True, text=True)
  449. return [f for f in result.stdout.strip().split("\n") if f]
  450. def read_file(repo, filepath):
  451. full_path = os.path.join(REPOS_DIR, repo, filepath)
  452. if not os.path.exists(full_path):
  453. return None
  454. with open(full_path, "r", errors="replace") as f:
  455. return f.read()
  456. def git_log(repo, n=5):
  457. result = subprocess.run(["git", "log", f"-{n}", "--oneline"],
  458. cwd=os.path.join(REPOS_DIR, repo), capture_output=True, text=True)
  459. return result.stdout.strip()
  460. def git_pull(repo):
  461. result = subprocess.run(["git", "pull"],
  462. cwd=os.path.join(REPOS_DIR, repo), capture_output=True, text=True)
  463. return result.stdout.strip() or result.stderr.strip()
  464. # ─── Google Auth ──────────────────────────────────────────────────────────────
  465. def get_google_creds():
  466. creds = None
  467. if os.path.exists(GOOGLE_TOKEN_FILE):
  468. creds = Credentials.from_authorized_user_file(GOOGLE_TOKEN_FILE, GOOGLE_SCOPES)
  469. if creds and creds.expired and creds.refresh_token:
  470. creds.refresh(Request())
  471. with open(GOOGLE_TOKEN_FILE, "w") as f:
  472. f.write(creds.to_json())
  473. return creds if (creds and creds.valid) else None
  474. # ─── Google Calendar ──────────────────────────────────────────────────────────
  475. def get_calendar_events(max_results=10):
  476. creds = get_google_creds()
  477. if not creds:
  478. return None
  479. service = build("calendar", "v3", credentials=creds)
  480. now = datetime.utcnow().isoformat() + "Z"
  481. events_result = service.events().list(
  482. calendarId="primary",
  483. timeMin=now,
  484. maxResults=max_results,
  485. singleEvents=True,
  486. orderBy="startTime"
  487. ).execute()
  488. return events_result.get("items", [])
  489. # ─── Gmail ────────────────────────────────────────────────────────────────────
  490. def get_gmail_messages(max_results=5):
  491. creds = get_google_creds()
  492. if not creds:
  493. return None
  494. service = build("gmail", "v1", credentials=creds)
  495. result = service.users().messages().list(
  496. userId="me", maxResults=max_results, q="is:unread"
  497. ).execute()
  498. messages = result.get("messages", [])
  499. emails = []
  500. for msg in messages:
  501. detail = service.users().messages().get(
  502. userId="me", id=msg["id"], format="metadata",
  503. metadataHeaders=["From", "Subject", "Date"]
  504. ).execute()
  505. headers = {h["name"]: h["value"] for h in detail["payload"]["headers"]}
  506. emails.append({
  507. "id": msg["id"],
  508. "thread_id": detail.get("threadId", ""),
  509. "message_id": headers.get("Message-ID", ""),
  510. "from": headers.get("From", ""),
  511. "subject": headers.get("Subject", ""),
  512. "date": headers.get("Date", "")
  513. })
  514. return emails
  515. def get_email_body(msg_id: str) -> str:
  516. creds = get_google_creds()
  517. if not creds:
  518. return ""
  519. try:
  520. service = build("gmail", "v1", credentials=creds)
  521. detail = service.users().messages().get(userId="me", id=msg_id, format="full").execute()
  522. parts = detail.get("payload", {}).get("parts", [])
  523. for part in parts:
  524. if part.get("mimeType") == "text/plain":
  525. data = part["body"].get("data", "")
  526. import base64 as b64
  527. return b64.urlsafe_b64decode(data + "==").decode("utf-8", errors="ignore")[:1500]
  528. # Pas de parts — corps direct
  529. body_data = detail.get("payload", {}).get("body", {}).get("data", "")
  530. if body_data:
  531. import base64 as b64
  532. return b64.urlsafe_b64decode(body_data + "==").decode("utf-8", errors="ignore")[:1500]
  533. except Exception:
  534. pass
  535. return ""
  536. def send_email_reply(to: str, subject: str, body: str, thread_id: str, in_reply_to: str) -> bool:
  537. import email.mime.text
  538. import base64 as b64
  539. creds = get_google_creds()
  540. if not creds:
  541. return False
  542. try:
  543. service = build("gmail", "v1", credentials=creds)
  544. msg = email.mime.text.MIMEText(body)
  545. msg["To"] = to
  546. msg["Subject"] = subject if subject.startswith("Re:") else f"Re: {subject}"
  547. if in_reply_to:
  548. msg["In-Reply-To"] = in_reply_to
  549. msg["References"] = in_reply_to
  550. raw = b64.urlsafe_b64encode(msg.as_bytes()).decode()
  551. service.users().messages().send(
  552. userId="me",
  553. body={"raw": raw, "threadId": thread_id}
  554. ).execute()
  555. return True
  556. except Exception as e:
  557. logger.error(f"Erreur envoi email : {e}")
  558. return False
  559. # En attente de confirmation d'envoi d'email
  560. pending_replies: dict = {}
  561. # ─── Notes ────────────────────────────────────────────────────────────────────
  562. def notes_path(user_id):
  563. return os.path.join(MEMORY_DIR, f"{user_id}_notes.json")
  564. def load_notes(user_id):
  565. path = notes_path(user_id)
  566. if os.path.exists(path):
  567. with open(path, "r") as f:
  568. return json.load(f)
  569. return []
  570. def save_notes(user_id, notes):
  571. with open(notes_path(user_id), "w") as f:
  572. json.dump(notes, f, ensure_ascii=False, indent=2)
  573. def add_note(user_id, content):
  574. notes = load_notes(user_id)
  575. note = {
  576. "id": len(notes) + 1,
  577. "content": content,
  578. "date": datetime.now().strftime("%Y-%m-%d %H:%M")
  579. }
  580. notes.append(note)
  581. save_notes(user_id, notes)
  582. return note
  583. def search_notes(user_id, query):
  584. notes = load_notes(user_id)
  585. query_lower = query.lower()
  586. return [n for n in notes if query_lower in n["content"].lower()]
  587. def search_memory(user_id, query):
  588. results = []
  589. query_lower = query.lower()
  590. # Chercher dans toutes les conversations
  591. for agent in AGENTS:
  592. path = memory_path(user_id, agent)
  593. if not os.path.exists(path):
  594. continue
  595. msgs = json.load(open(path))
  596. for msg in msgs:
  597. if msg["role"] in ("user", "assistant") and query_lower in msg["content"].lower():
  598. results.append({
  599. "agent": agent,
  600. "role": msg["role"],
  601. "content": msg["content"][:300]
  602. })
  603. # Chercher dans les notes
  604. notes = search_notes(user_id, query)
  605. return results, notes
  606. # ─── Météo ────────────────────────────────────────────────────────────────────
  607. async def get_weather(city: str) -> str:
  608. try:
  609. async with httpx.AsyncClient(timeout=10) as client:
  610. response = await client.get(f"https://wttr.in/{city}?format=3&lang=fr")
  611. return f"🌤️ {response.text.strip()}"
  612. except Exception as e:
  613. return f"Impossible de récupérer la météo : {e}"
  614. # ─── Recherche web ────────────────────────────────────────────────────────────
  615. def web_search(query: str, max_results: int = 5) -> str:
  616. try:
  617. with DDGS() as ddgs:
  618. results = list(ddgs.text(query, region="fr-fr", max_results=max_results))
  619. if not results:
  620. return "Aucun résultat trouvé."
  621. msg = ""
  622. for r in results:
  623. msg += f"• *{r.get('title','')}*\n{r.get('body','')[:200]}\n\n"
  624. return msg.strip()
  625. except Exception as e:
  626. return f"Erreur recherche : {e}"
  627. # ─── Actualités ───────────────────────────────────────────────────────────────
  628. def get_news(category: str = "tech", max_items: int = 5) -> str:
  629. feeds = NEWS_FEEDS.get(category, NEWS_FEEDS["tech"])
  630. items = []
  631. for url in feeds:
  632. try:
  633. feed = feedparser.parse(url)
  634. for entry in feed.entries[:3]:
  635. items.append({
  636. "title": entry.get("title", ""),
  637. "summary": entry.get("summary", "")[:200]
  638. })
  639. except Exception:
  640. continue
  641. if not items:
  642. return "Impossible de récupérer les actualités."
  643. items = items[:max_items]
  644. msg = f"📰 *Actualités {category}*\n\n"
  645. for item in items:
  646. msg += f"• *{item['title']}*\n{item['summary']}\n\n"
  647. return msg.strip()
  648. # ─── Handlers ─────────────────────────────────────────────────────────────────
  649. async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
  650. user_id = update.effective_user.id
  651. profile = load_profile(user_id)
  652. nom = profile.get("nom")
  653. if nom:
  654. msg = f"Content de te retrouver {nom} ! Je suis Konan."
  655. else:
  656. msg = (
  657. "Bonjour ! Je suis Konan, ton orchestrateur d'agents IA.\n\n"
  658. "Agents : 💻 Coder · 🔍 Reviewer · 🏗️ Architect · 📊 Analyst\n"
  659. "Tu peux m'envoyer du texte, de la voix, des images ou ta localisation.\n\n"
  660. "/setnom Prenom — te présenter\n"
  661. "/monitoring — état du serveur\n"
  662. "/meteo [ville] — météo (ou partage ta position)\n"
  663. "/news [tech|politique|football] — actualités\n"
  664. "/recherche <query> — recherche web\n"
  665. "/note <contenu> — sauvegarder une note\n"
  666. "/notes — voir tes notes\n"
  667. "/chercher <mot> — chercher dans la mémoire\n"
  668. "/briefing 08:00 — briefing matinal automatique\n"
  669. "/rappel 30m Message — créer un rappel\n"
  670. "/repos — projets Git\n"
  671. "/agents — voir les agents\n"
  672. "/reset — réinitialiser"
  673. )
  674. await update.message.reply_text(msg)
  675. async def monitoring_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  676. status = get_server_status()
  677. await update.message.reply_text(status, parse_mode="Markdown")
  678. async def rappel_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  679. user_id = update.effective_user.id
  680. if len(context.args) < 2:
  681. await update.message.reply_text(
  682. "Usage : /rappel <temps> <message>\n\n"
  683. "Exemples :\n"
  684. "/rappel 30m Faire une pause\n"
  685. "/rappel 2h Revoir l'architecture\n"
  686. "/rappel 14:30 Réunion équipe"
  687. )
  688. return
  689. time_str = context.args[0]
  690. message = " ".join(context.args[1:])
  691. target_time = parse_time(time_str)
  692. if not target_time:
  693. await update.message.reply_text("Format de temps non reconnu. Exemples : 30m, 2h, 14:30")
  694. return
  695. reminder_id = f"{user_id}_{int(target_time.timestamp())}"
  696. bot = context.application.bot
  697. scheduler.add_job(
  698. send_reminder,
  699. "date",
  700. run_date=target_time,
  701. args=[bot, user_id, message, reminder_id],
  702. id=reminder_id
  703. )
  704. reminders = load_reminders()
  705. reminders.append({
  706. "id": reminder_id,
  707. "user_id": user_id,
  708. "message": message,
  709. "time": target_time.isoformat()
  710. })
  711. save_reminders(reminders)
  712. heure = target_time.strftime("%H:%M")
  713. await update.message.reply_text(f"⏰ Rappel créé pour {heure} : \"{message}\"")
  714. async def rappels_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  715. user_id = update.effective_user.id
  716. reminders = [r for r in load_reminders() if r["user_id"] == user_id]
  717. if not reminders:
  718. await update.message.reply_text("Aucun rappel en attente.")
  719. return
  720. msg = "⏰ Tes rappels :\n\n"
  721. for r in reminders:
  722. t = datetime.fromisoformat(r["time"]).strftime("%H:%M")
  723. msg += f"• {t} — {r['message']}\n"
  724. await update.message.reply_text(msg)
  725. async def agents_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  726. msg = "Agents disponibles :\n\n"
  727. for name, cfg in AGENTS.items():
  728. emoji = AGENT_EMOJIS.get(name, "🤖")
  729. msg += f"{emoji} *{name.capitalize()}* — {cfg['model']}\n"
  730. await update.message.reply_text(msg, parse_mode="Markdown")
  731. async def reset(update: Update, context: ContextTypes.DEFAULT_TYPE):
  732. user_id = update.effective_user.id
  733. for agent in AGENTS:
  734. path = memory_path(user_id, agent)
  735. if os.path.exists(path):
  736. os.remove(path)
  737. await update.message.reply_text("Mémoire réinitialisée. Profil conservé.")
  738. async def set_nom(update: Update, context: ContextTypes.DEFAULT_TYPE):
  739. user_id = update.effective_user.id
  740. if not context.args:
  741. await update.message.reply_text("Usage : /setnom Prenom")
  742. return
  743. nom = " ".join(context.args)
  744. profile = load_profile(user_id)
  745. profile["nom"] = nom
  746. save_profile(user_id, profile)
  747. await update.message.reply_text(f"Parfait, je m'en souviendrai {nom} !")
  748. async def set_projet(update: Update, context: ContextTypes.DEFAULT_TYPE):
  749. user_id = update.effective_user.id
  750. if not context.args:
  751. await update.message.reply_text("Usage : /setprojet Nom")
  752. return
  753. projet = " ".join(context.args)
  754. profile = load_profile(user_id)
  755. if projet not in profile["projets"]:
  756. profile["projets"].append(projet)
  757. save_profile(user_id, profile)
  758. await update.message.reply_text(f"Projet \"{projet}\" ajouté.")
  759. async def profil(update: Update, context: ContextTypes.DEFAULT_TYPE):
  760. user_id = update.effective_user.id
  761. profile = load_profile(user_id)
  762. nom = profile.get("nom") or "non défini"
  763. projets = ", ".join(profile.get("projets", [])) or "aucun"
  764. await update.message.reply_text(f"Nom : {nom}\nProjets : {projets}")
  765. async def repos_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  766. repos = list_repos()
  767. if not repos:
  768. await update.message.reply_text("Aucun repo cloné.\n/clone <url> pour en ajouter un.")
  769. return
  770. msg = "Repos disponibles :\n\n"
  771. for r in repos:
  772. msg += f"📁 {r}\n"
  773. msg += "\n/fichiers <repo> · /log <repo> · /analyser <repo> <fichier>"
  774. await update.message.reply_text(msg)
  775. async def clone_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  776. if not context.args:
  777. await update.message.reply_text("Usage : /clone <url>")
  778. return
  779. url = context.args[0]
  780. name = url.rstrip("/").split("/")[-1].replace(".git", "")
  781. dest = os.path.join(REPOS_DIR, name)
  782. await update.message.reply_text(f"Clonage de {name}...")
  783. result = subprocess.run(["git", "clone", url, dest], capture_output=True, text=True)
  784. if result.returncode == 0:
  785. await update.message.reply_text(f"✅ {name} cloné.")
  786. else:
  787. await update.message.reply_text(f"Erreur : {result.stderr}")
  788. async def fichiers_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  789. if not context.args:
  790. await update.message.reply_text("Usage : /fichiers <repo>")
  791. return
  792. repo = context.args[0]
  793. files = list_files(repo)
  794. if files is None:
  795. await update.message.reply_text(f"Repo '{repo}' introuvable.")
  796. return
  797. msg = f"Fichiers de {repo} :\n\n" + "\n".join(files[:50])
  798. if len(files) > 50:
  799. msg += f"\n... et {len(files) - 50} autres"
  800. await update.message.reply_text(msg)
  801. async def log_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  802. if not context.args:
  803. await update.message.reply_text("Usage : /log <repo>")
  804. return
  805. repo = context.args[0]
  806. log = git_log(repo)
  807. await update.message.reply_text(f"Derniers commits de {repo} :\n\n{log}" if log else "Repo introuvable.")
  808. async def lire_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  809. if len(context.args) < 2:
  810. await update.message.reply_text("Usage : /lire <repo> <fichier>")
  811. return
  812. repo = context.args[0]
  813. filepath = " ".join(context.args[1:])
  814. content = read_file(repo, filepath)
  815. if content is None:
  816. await update.message.reply_text(f"Fichier '{filepath}' introuvable.")
  817. return
  818. if len(content) > 3000:
  819. content = content[:3000] + "\n... (tronqué)"
  820. await update.message.reply_text(f"```\n{content}\n```", parse_mode="Markdown")
  821. async def analyser_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  822. user_id = update.effective_user.id
  823. if len(context.args) < 2:
  824. await update.message.reply_text("Usage : /analyser <repo> <fichier>")
  825. return
  826. repo = context.args[0]
  827. filepath = " ".join(context.args[1:])
  828. content = read_file(repo, filepath)
  829. if content is None:
  830. await update.message.reply_text(f"Fichier '{filepath}' introuvable.")
  831. return
  832. await update.message.reply_text(f"🔍 Analyse de {filepath}...")
  833. message = f"Analyse ce fichier et donne résumé, points forts et problèmes :\n\n```\n{content[:4000]}\n```"
  834. reply, _ = await ask_agent(user_id, message, "reviewer")
  835. await update.message.reply_text(reply)
  836. async def pull_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  837. if not context.args:
  838. await update.message.reply_text("Usage : /pull <repo>")
  839. return
  840. result = git_pull(context.args[0])
  841. await update.message.reply_text(result)
  842. async def google_connect_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  843. user_id = update.effective_user.id
  844. if get_google_creds():
  845. await update.message.reply_text("✅ Google déjà connecté.\n/calendrier — voir tes événements\n/mails — voir tes mails")
  846. return
  847. flow = Flow.from_client_secrets_file(
  848. GOOGLE_CREDS_FILE,
  849. scopes=GOOGLE_SCOPES,
  850. redirect_uri="urn:ietf:wg:oauth:2.0:oob"
  851. )
  852. auth_url, _ = flow.authorization_url(prompt="consent")
  853. pending_oauth[user_id] = flow
  854. await update.message.reply_text(
  855. "🔗 Connexion Google\n\n"
  856. "1. Clique sur ce lien et autorise l'accès :\n"
  857. f"{auth_url}\n\n"
  858. "2. Copie le code affiché\n"
  859. "3. Envoie-le moi avec /google_code LECODICI"
  860. )
  861. async def google_code_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  862. user_id = update.effective_user.id
  863. if not context.args:
  864. await update.message.reply_text("Usage : /google_code <code>")
  865. return
  866. if user_id not in pending_oauth:
  867. await update.message.reply_text("Lance d'abord /google_connect")
  868. return
  869. code = context.args[0]
  870. flow = pending_oauth[user_id]
  871. try:
  872. flow.fetch_token(code=code)
  873. creds = flow.credentials
  874. with open(GOOGLE_TOKEN_FILE, "w") as f:
  875. f.write(creds.to_json())
  876. del pending_oauth[user_id]
  877. await update.message.reply_text(
  878. "✅ Google connecté avec succès !\n\n"
  879. "/calendrier — voir tes événements\n"
  880. "/mails — voir tes mails non lus"
  881. )
  882. except Exception as e:
  883. await update.message.reply_text(f"Erreur d'authentification : {e}")
  884. async def calendrier_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  885. await update.message.reply_text("📅 Chargement du calendrier...")
  886. events = get_calendar_events()
  887. if events is None:
  888. await update.message.reply_text("Google non connecté. Lance /google_connect")
  889. return
  890. if not events:
  891. await update.message.reply_text("Aucun événement à venir.")
  892. return
  893. msg = "📅 *Prochains événements :*\n\n"
  894. for event in events:
  895. start = event["start"].get("dateTime", event["start"].get("date", ""))
  896. if "T" in start:
  897. dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
  898. start_str = dt.strftime("%d/%m %H:%M")
  899. else:
  900. start_str = start
  901. msg += f"• *{event.get('summary', 'Sans titre')}*\n 📅 {start_str}\n\n"
  902. await update.message.reply_text(msg, parse_mode="Markdown")
  903. async def mails_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  904. await update.message.reply_text("📬 Chargement des mails...")
  905. emails = get_gmail_messages()
  906. if emails is None:
  907. await update.message.reply_text("Google non connecté. Lance /google_connect")
  908. return
  909. if not emails:
  910. await update.message.reply_text("Aucun mail non lu.")
  911. return
  912. msg = "📬 *Mails non lus :*\n\n"
  913. for e in emails:
  914. msg += f"• *{e['subject']}*\n De : {e['from'][:50]}\n\n"
  915. await update.message.reply_text(msg, parse_mode="Markdown")
  916. async def briefing_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  917. user_id = update.effective_user.id
  918. if not context.args:
  919. briefings = load_briefing_config()
  920. cfg = briefings.get(str(user_id))
  921. if cfg:
  922. await update.message.reply_text(
  923. f"⏰ Briefing matinal configuré à {cfg['heure']}.\n\n"
  924. f"/briefing 07:30 — changer l'heure\n"
  925. f"/briefing stop — désactiver\n"
  926. f"/briefing_test — recevoir un briefing maintenant"
  927. )
  928. else:
  929. await update.message.reply_text(
  930. "Aucun briefing configuré.\n\n"
  931. "Usage : /briefing 08:00\n"
  932. "/briefing_test — tester maintenant"
  933. )
  934. return
  935. if context.args[0].lower() == "stop":
  936. briefings = load_briefing_config()
  937. briefings.pop(str(user_id), None)
  938. save_briefing_config(briefings)
  939. job_id = f"briefing_{user_id}"
  940. if scheduler.get_job(job_id):
  941. scheduler.remove_job(job_id)
  942. await update.message.reply_text("Briefing matinal désactivé.")
  943. return
  944. heure = context.args[0]
  945. if ":" not in heure:
  946. await update.message.reply_text("Format : /briefing 08:00")
  947. return
  948. h, m = heure.split(":")
  949. briefings = load_briefing_config()
  950. briefings[str(user_id)] = {"heure": heure}
  951. save_briefing_config(briefings)
  952. bot = context.application.bot
  953. job_id = f"briefing_{user_id}"
  954. if scheduler.get_job(job_id):
  955. scheduler.remove_job(job_id)
  956. scheduler.add_job(
  957. send_briefing,
  958. "cron",
  959. hour=int(h),
  960. minute=int(m),
  961. args=[bot, user_id],
  962. id=job_id,
  963. replace_existing=True
  964. )
  965. await update.message.reply_text(
  966. f"✅ Briefing matinal configuré à {heure}.\n"
  967. f"Chaque matin tu recevras : météo, news tech, foot, tes notes et l'état du serveur.\n\n"
  968. f"/briefing_test — tester maintenant"
  969. )
  970. async def briefing_test_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  971. user_id = update.effective_user.id
  972. await update.message.reply_text("Génération du briefing...")
  973. bot = context.application.bot
  974. await send_briefing(bot, user_id)
  975. async def note_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  976. user_id = update.effective_user.id
  977. if not context.args:
  978. await update.message.reply_text("Usage : /note <contenu>\nEx: /note Je préfère Rust pour le backend")
  979. return
  980. content = " ".join(context.args)
  981. note = add_note(user_id, content)
  982. await update.message.reply_text(f"📝 Note #{note['id']} sauvegardée.")
  983. async def notes_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  984. user_id = update.effective_user.id
  985. notes = load_notes(user_id)
  986. if not notes:
  987. await update.message.reply_text("Aucune note. Utilise /note <contenu> pour en créer une.")
  988. return
  989. msg = "📝 *Tes notes :*\n\n"
  990. for n in notes[-10:]:
  991. msg += f"#{n['id']} [{n['date']}]\n{n['content']}\n\n"
  992. await update.message.reply_text(msg, parse_mode="Markdown")
  993. async def chercher_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  994. user_id = update.effective_user.id
  995. if not context.args:
  996. await update.message.reply_text("Usage : /chercher <mot-clé>")
  997. return
  998. query = " ".join(context.args)
  999. await update.message.reply_text(f"🔍 Recherche dans la mémoire : \"{query}\"...")
  1000. memory_results, note_results = search_memory(user_id, query)
  1001. msg = ""
  1002. if note_results:
  1003. msg += f"📝 *Notes ({len(note_results)}) :*\n"
  1004. for n in note_results[:3]:
  1005. msg += f"• [{n['date']}] {n['content']}\n"
  1006. msg += "\n"
  1007. if memory_results:
  1008. msg += f"💬 *Conversations ({len(memory_results)}) :*\n"
  1009. for r in memory_results[:5]:
  1010. role_icon = "👤" if r["role"] == "user" else "🤖"
  1011. msg += f"{role_icon} [{r['agent']}] {r['content'][:200]}\n\n"
  1012. if not msg:
  1013. msg = f"Aucun résultat pour \"{query}\" dans la mémoire ou les notes."
  1014. await update.message.reply_text(msg, parse_mode="Markdown")
  1015. async def meteo_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  1016. user_id = update.effective_user.id
  1017. profile = load_profile(user_id)
  1018. city = " ".join(context.args) if context.args else profile.get("ville", DEFAULT_CITY)
  1019. result = await get_weather(city)
  1020. await update.message.reply_text(result)
  1021. async def recherche_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  1022. user_id = update.effective_user.id
  1023. if not context.args:
  1024. await update.message.reply_text("Usage : /recherche <requête>")
  1025. return
  1026. query = " ".join(context.args)
  1027. await update.message.reply_text(f"🔍 Recherche : {query}...")
  1028. results = web_search(query)
  1029. await update.message.reply_text(results, parse_mode="Markdown")
  1030. async def news_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  1031. category = context.args[0].lower() if context.args else "tech"
  1032. if category not in NEWS_FEEDS:
  1033. await update.message.reply_text(f"Catégories disponibles : {', '.join(NEWS_FEEDS.keys())}")
  1034. return
  1035. await update.message.reply_text(f"📰 Chargement des news {category}...")
  1036. news = get_news(category)
  1037. await update.message.reply_text(news, parse_mode="Markdown")
  1038. async def handle_location(update: Update, context: ContextTypes.DEFAULT_TYPE):
  1039. user_id = update.effective_user.id
  1040. location = update.message.location
  1041. lat, lon = location.latitude, location.longitude
  1042. await update.message.reply_text("📍 Localisation reçue, récupération météo...")
  1043. try:
  1044. async with httpx.AsyncClient(timeout=10) as client:
  1045. response = await client.get(f"https://wttr.in/{lat},{lon}?format=3&lang=fr")
  1046. result = f"🌤️ {response.text.strip()}"
  1047. # Sauvegarder la ville dans le profil
  1048. city_resp = await client.get(f"https://wttr.in/{lat},{lon}?format=%l")
  1049. city = city_resp.text.strip()
  1050. profile = load_profile(user_id)
  1051. profile["ville"] = f"{lat},{lon}"
  1052. save_profile(user_id, profile)
  1053. await update.message.reply_text(f"{result}\n\n📍 Position sauvegardée pour les prochaines fois.")
  1054. except Exception as e:
  1055. await update.message.reply_text(f"Erreur météo : {e}")
  1056. async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
  1057. user_id = update.effective_user.id
  1058. text = update.message.text
  1059. # Détection automatique de note
  1060. note_triggers = ["note que", "souviens-toi que", "retiens que", "n'oublie pas que", "mémorise que"]
  1061. text_lower = text.lower()
  1062. for trigger in note_triggers:
  1063. if text_lower.startswith(trigger):
  1064. content = text[len(trigger):].strip()
  1065. note = add_note(user_id, content)
  1066. await update.message.reply_text(f"📝 Noté ! (#{note['id']}) : \"{content}\"")
  1067. return
  1068. # Confirmation d'envoi d'email en attente
  1069. if user_id in pending_replies:
  1070. if text_lower.strip() in ["oui", "yes", "envoie", "ok", "confirme"]:
  1071. pr = pending_replies.pop(user_id)
  1072. ok = send_email_reply(pr["to"], pr["subject"], pr["draft"], pr["thread_id"], pr["message_id"])
  1073. if ok:
  1074. await update.message.reply_text("✅ Email envoyé !")
  1075. else:
  1076. await update.message.reply_text("Erreur lors de l'envoi. Vérifie la connexion Google.")
  1077. return
  1078. elif text_lower.strip() in ["non", "no", "annule", "cancel"]:
  1079. pending_replies.pop(user_id)
  1080. await update.message.reply_text("❌ Email annulé.")
  1081. return
  1082. await update.message.reply_text("⏳")
  1083. intent_data = await detect_intent(text)
  1084. if await execute_intent(intent_data, user_id, update, context):
  1085. return
  1086. agent = await detect_agent(text)
  1087. emoji = AGENT_EMOJIS.get(agent, "🤖")
  1088. await update.message.reply_text(f"{emoji} Agent {agent.capitalize()}...")
  1089. reply, _ = await ask_agent(user_id, text, agent)
  1090. await update.message.reply_text(reply)
  1091. async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
  1092. user_id = update.effective_user.id
  1093. await update.message.reply_text("🎤 Transcription...")
  1094. voice = update.message.voice
  1095. file = await context.bot.get_file(voice.file_id)
  1096. with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
  1097. tmp_path = tmp.name
  1098. await file.download_to_drive(tmp_path)
  1099. try:
  1100. text = await transcribe_voice(tmp_path)
  1101. await update.message.reply_text(f"« {text} »")
  1102. intent_data = await detect_intent(text)
  1103. if not await execute_intent(intent_data, user_id, update, context):
  1104. agent = await detect_agent(text)
  1105. emoji = AGENT_EMOJIS.get(agent, "🤖")
  1106. await update.message.reply_text(f"{emoji} Agent {agent.capitalize()}...")
  1107. reply, _ = await ask_agent(user_id, text, agent)
  1108. await update.message.reply_text(reply)
  1109. audio_path = text_to_audio(reply)
  1110. with open(audio_path, "rb") as audio_file:
  1111. await update.message.reply_voice(audio_file)
  1112. os.unlink(audio_path)
  1113. except Exception as e:
  1114. await update.message.reply_text(f"Erreur : {e}")
  1115. finally:
  1116. os.unlink(tmp_path)
  1117. async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
  1118. await update.message.reply_text("🖼️ Analyse de l'image...")
  1119. photo = update.message.photo[-1]
  1120. file = await context.bot.get_file(photo.file_id)
  1121. with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
  1122. tmp_path = tmp.name
  1123. await file.download_to_drive(tmp_path)
  1124. try:
  1125. caption = update.message.caption or "Décris cette image en détail en français."
  1126. result = await analyze_image(tmp_path, caption)
  1127. await update.message.reply_text(result)
  1128. except Exception as e:
  1129. await update.message.reply_text(f"Erreur : {e}")
  1130. finally:
  1131. os.unlink(tmp_path)
  1132. # ─── Main ─────────────────────────────────────────────────────────────────────
  1133. BRIEFING_FILE = os.path.join(os.path.expanduser("~/agent"), "briefing.json")
  1134. GOOGLE_CREDS_FILE = os.path.expanduser("~/agent/google_credentials.json")
  1135. GOOGLE_TOKEN_FILE = os.path.expanduser("~/agent/google_token.json")
  1136. GOOGLE_SCOPES = [
  1137. "https://www.googleapis.com/auth/calendar.readonly",
  1138. "https://www.googleapis.com/auth/gmail.readonly",
  1139. "https://www.googleapis.com/auth/gmail.send"
  1140. ]
  1141. # Stockage temporaire des flows OAuth en attente
  1142. pending_oauth = {}
  1143. def load_briefing_config():
  1144. if os.path.exists(BRIEFING_FILE):
  1145. with open(BRIEFING_FILE, "r") as f:
  1146. return json.load(f)
  1147. return {}
  1148. def save_briefing_config(config):
  1149. with open(BRIEFING_FILE, "w") as f:
  1150. json.dump(config, f, ensure_ascii=False, indent=2)
  1151. async def send_briefing(bot: Bot, user_id: int):
  1152. try:
  1153. profile = load_profile(user_id)
  1154. nom = profile.get("nom", "")
  1155. ville = profile.get("ville", DEFAULT_CITY)
  1156. notes = load_notes(user_id)
  1157. notes_recentes = notes[-3:] if notes else []
  1158. greeting = f"☀️ Bonjour {nom} !\n\n" if nom else "☀️ Bonjour !\n\n"
  1159. msg = greeting
  1160. # Météo
  1161. meteo = await get_weather(ville)
  1162. msg += f"{meteo}\n\n"
  1163. # News tech
  1164. tech = get_news("tech", max_items=3)
  1165. msg += f"📰 *Tech du jour :*\n"
  1166. lines = [l for l in tech.split("\n") if l.startswith("•")][:3]
  1167. msg += "\n".join(lines) + "\n\n"
  1168. # Football
  1169. foot = get_news("football", max_items=2)
  1170. msg += f"⚽ *Football :*\n"
  1171. lines = [l for l in foot.split("\n") if l.startswith("•")][:2]
  1172. msg += "\n".join(lines) + "\n\n"
  1173. # Notes récentes
  1174. if notes_recentes:
  1175. msg += "📝 *Tes dernières notes :*\n"
  1176. for n in notes_recentes:
  1177. msg += f"• {n['content']}\n"
  1178. msg += "\n"
  1179. # Calendrier
  1180. events = get_calendar_events(max_results=3)
  1181. if events:
  1182. msg += "📅 *Agenda du jour :*\n"
  1183. for event in events:
  1184. start = event["start"].get("dateTime", event["start"].get("date", ""))
  1185. if "T" in start:
  1186. dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
  1187. start_str = dt.strftime("%H:%M")
  1188. else:
  1189. start_str = start
  1190. msg += f"• {start_str} — {event.get('summary', 'Sans titre')}\n"
  1191. msg += "\n"
  1192. # Mails non lus
  1193. emails = get_gmail_messages(max_results=3)
  1194. if emails:
  1195. msg += f"📬 *{len(emails)} mail(s) non lu(s) :*\n"
  1196. for e in emails[:3]:
  1197. msg += f"• {e['subject'][:50]}\n"
  1198. msg += "\n"
  1199. # Monitoring
  1200. cpu = psutil.cpu_percent(interval=0.5)
  1201. ram = psutil.virtual_memory()
  1202. msg += f"🖥️ Serveur : CPU {cpu:.0f}% · RAM {ram.percent:.0f}%"
  1203. await bot.send_message(chat_id=user_id, text=msg, parse_mode="Markdown")
  1204. logger.info(f"Briefing envoyé à {user_id}")
  1205. except Exception as e:
  1206. logger.error(f"Erreur briefing : {e}")
  1207. async def post_init(application):
  1208. scheduler.start()
  1209. bot = application.bot
  1210. # Recharger les rappels persistés
  1211. reminders = load_reminders()
  1212. for r in reminders:
  1213. target = datetime.fromisoformat(r["time"])
  1214. if target > datetime.now():
  1215. scheduler.add_job(
  1216. send_reminder,
  1217. "date",
  1218. run_date=target,
  1219. args=[bot, r["user_id"], r["message"], r["id"]],
  1220. id=r["id"],
  1221. replace_existing=True
  1222. )
  1223. logger.info(f"{len(reminders)} rappel(s) rechargé(s).")
  1224. # Recharger les briefings configurés
  1225. briefings = load_briefing_config()
  1226. for user_id_str, cfg in briefings.items():
  1227. user_id = int(user_id_str)
  1228. heure = cfg.get("heure", "08:00")
  1229. h, m = heure.split(":")
  1230. scheduler.add_job(
  1231. send_briefing,
  1232. "cron",
  1233. hour=int(h),
  1234. minute=int(m),
  1235. args=[bot, user_id],
  1236. id=f"briefing_{user_id}",
  1237. replace_existing=True
  1238. )
  1239. logger.info(f"Briefing programmé à {heure} pour {user_id}")
  1240. # Démarrer l'API HTTP pour le client PC
  1241. api_app = web.Application()
  1242. api_app.router.add_post("/api/chat", api_chat_handler)
  1243. runner = web.AppRunner(api_app)
  1244. await runner.setup()
  1245. site = web.TCPSite(runner, "0.0.0.0", 8765)
  1246. await site.start()
  1247. logger.info("API HTTP démarrée sur port 8765")
  1248. async def api_chat_handler(request):
  1249. try:
  1250. data = await request.json()
  1251. text = data.get("text", "").strip()
  1252. user_id = data.get("user_id", "pc_user")
  1253. if not text:
  1254. return web.json_response({"error": "texte vide"}, status=400)
  1255. agent = await detect_agent(text)
  1256. response = await ask_agent(user_id, text, agent)
  1257. return web.json_response({"response": response, "agent": agent})
  1258. except Exception as e:
  1259. logger.error(f"API erreur : {e}")
  1260. return web.json_response({"error": str(e)}, status=500)
  1261. if __name__ == "__main__":
  1262. app = ApplicationBuilder().token(TELEGRAM_TOKEN).post_init(post_init).build()
  1263. app.add_handler(CommandHandler("start", start))
  1264. app.add_handler(CommandHandler("reset", reset))
  1265. app.add_handler(CommandHandler("agents", agents_cmd))
  1266. app.add_handler(CommandHandler("setnom", set_nom))
  1267. app.add_handler(CommandHandler("setprojet", set_projet))
  1268. app.add_handler(CommandHandler("profil", profil))
  1269. app.add_handler(CommandHandler("monitoring", monitoring_cmd))
  1270. app.add_handler(CommandHandler("rappel", rappel_cmd))
  1271. app.add_handler(CommandHandler("rappels", rappels_cmd))
  1272. app.add_handler(CommandHandler("repos", repos_cmd))
  1273. app.add_handler(CommandHandler("clone", clone_cmd))
  1274. app.add_handler(CommandHandler("fichiers", fichiers_cmd))
  1275. app.add_handler(CommandHandler("lire", lire_cmd))
  1276. app.add_handler(CommandHandler("analyser", analyser_cmd))
  1277. app.add_handler(CommandHandler("log", log_cmd))
  1278. app.add_handler(CommandHandler("pull", pull_cmd))
  1279. app.add_handler(CommandHandler("briefing", briefing_cmd))
  1280. app.add_handler(CommandHandler("briefing_test", briefing_test_cmd))
  1281. app.add_handler(CommandHandler("note", note_cmd))
  1282. app.add_handler(CommandHandler("notes", notes_cmd))
  1283. app.add_handler(CommandHandler("chercher", chercher_cmd))
  1284. app.add_handler(CommandHandler("meteo", meteo_cmd))
  1285. app.add_handler(CommandHandler("recherche", recherche_cmd))
  1286. app.add_handler(CommandHandler("news", news_cmd))
  1287. app.add_handler(CommandHandler("google_connect", google_connect_cmd))
  1288. app.add_handler(CommandHandler("google_code", google_code_cmd))
  1289. app.add_handler(CommandHandler("calendrier", calendrier_cmd))
  1290. app.add_handler(CommandHandler("mails", mails_cmd))
  1291. app.add_handler(MessageHandler(filters.LOCATION, handle_location))
  1292. app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
  1293. app.add_handler(MessageHandler(filters.VOICE, handle_voice))
  1294. app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
  1295. logger.info("Konan demarre - Google + Météo + News + Recherche + Monitoring + Rappels + Git + Vision + Voix + Agents.")
  1296. app.run_polling()