bot.py 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338
  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":"conversation"} — tout le reste\n\n'
  249. f'Message : "{text}"\n\nJSON :'
  250. )
  251. try:
  252. async with httpx.AsyncClient(timeout=15) as client:
  253. resp = await client.post(OLLAMA_URL, json={
  254. "model": "qwen2.5:3b",
  255. "messages": [{"role": "user", "content": prompt}],
  256. "stream": False,
  257. "options": {"num_predict": 60, "temperature": 0}
  258. })
  259. raw = resp.json()["message"]["content"].strip()
  260. match = _re.search(r'\{[^}]+\}', raw)
  261. if match:
  262. return json.loads(match.group())
  263. except Exception:
  264. pass
  265. return {"intent": "conversation"}
  266. async def execute_intent(intent_data: dict, user_id: int, update, context) -> bool:
  267. """Exécute l'intention détectée. Retourne True si traitée, False pour conversation."""
  268. intent = intent_data.get("intent", "conversation")
  269. if intent == "meteo":
  270. city = intent_data.get("city") or DEFAULT_CITY
  271. result = await get_weather(city)
  272. await update.message.reply_text(result)
  273. return True
  274. elif intent == "news":
  275. category = intent_data.get("category", "tech")
  276. if category not in NEWS_FEEDS:
  277. category = "tech"
  278. result = get_news(category)
  279. await update.message.reply_text(result, parse_mode="Markdown")
  280. return True
  281. elif intent == "recherche":
  282. query = intent_data.get("query", "")
  283. if not query:
  284. return False
  285. result = web_search(query)
  286. await update.message.reply_text(result, parse_mode="Markdown")
  287. return True
  288. elif intent == "monitoring":
  289. status = get_server_status()
  290. await update.message.reply_text(status, parse_mode="Markdown")
  291. return True
  292. elif intent == "calendrier":
  293. events = get_calendar_events()
  294. if events is None:
  295. await update.message.reply_text("Google non connecté. Lance /google_connect")
  296. elif not events:
  297. await update.message.reply_text("Aucun événement à venir.")
  298. else:
  299. msg = "📅 *Prochains événements :*\n\n"
  300. for event in events:
  301. start = event["start"].get("dateTime", event["start"].get("date", ""))
  302. if "T" in start:
  303. dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
  304. start_str = dt.strftime("%d/%m %H:%M")
  305. else:
  306. start_str = start
  307. msg += f"• *{event.get('summary', 'Sans titre')}*\n 📅 {start_str}\n\n"
  308. await update.message.reply_text(msg, parse_mode="Markdown")
  309. return True
  310. elif intent == "mails":
  311. emails = get_gmail_messages()
  312. if emails is None:
  313. await update.message.reply_text("Google non connecté. Lance /google_connect")
  314. elif not emails:
  315. await update.message.reply_text("Aucun mail non lu.")
  316. else:
  317. msg = "📬 *Mails non lus :*\n\n"
  318. for e in emails:
  319. msg += f"• *{e['subject']}*\n De : {e['from'][:50]}\n\n"
  320. await update.message.reply_text(msg, parse_mode="Markdown")
  321. return True
  322. elif intent == "rappel":
  323. time_str = intent_data.get("time", "")
  324. message = intent_data.get("message", "")
  325. if not time_str or not message:
  326. await update.message.reply_text("Précise l'heure et le message du rappel.")
  327. return True
  328. target_time = parse_time(time_str)
  329. if not target_time:
  330. await update.message.reply_text(f"Format de temps non reconnu : {time_str}")
  331. return True
  332. reminder_id = f"{user_id}_{int(target_time.timestamp())}"
  333. bot = context.application.bot
  334. scheduler.add_job(
  335. send_reminder, "date",
  336. run_date=target_time,
  337. args=[bot, user_id, message, reminder_id],
  338. id=reminder_id, replace_existing=True
  339. )
  340. reminders = load_reminders()
  341. reminders.append({"id": reminder_id, "user_id": user_id, "message": message, "time": target_time.isoformat()})
  342. save_reminders(reminders)
  343. await update.message.reply_text(f"⏰ Rappel créé : \"{message}\" à {target_time.strftime('%H:%M le %d/%m')}")
  344. return True
  345. return False
  346. # ─── Ollama ───────────────────────────────────────────────────────────────────
  347. async def ask_agent(user_id, message, agent="konan"):
  348. messages = load_conversation(user_id, agent)
  349. messages.append({"role": "user", "content": message})
  350. if len(messages) > 21:
  351. messages = [messages[0]] + messages[-20:]
  352. try:
  353. async with httpx.AsyncClient(timeout=180) as client:
  354. response = await client.post(OLLAMA_URL, json={
  355. "model": AGENTS[agent]["model"],
  356. "messages": messages,
  357. "stream": False
  358. })
  359. data = response.json()
  360. reply = data["message"]["content"]
  361. messages.append({"role": "assistant", "content": reply})
  362. save_conversation(user_id, messages, agent)
  363. return reply, agent
  364. except Exception as e:
  365. return f"Erreur : {e}", agent
  366. # ─── Vision ───────────────────────────────────────────────────────────────────
  367. async def analyze_image(image_path: str, question: str = "Décris cette image en détail en français.") -> str:
  368. with open(image_path, "rb") as f:
  369. image_b64 = base64.b64encode(f.read()).decode("utf-8")
  370. try:
  371. async with httpx.AsyncClient(timeout=180) as client:
  372. response = await client.post(OLLAMA_GENERATE_URL, json={
  373. "model": "moondream",
  374. "prompt": question,
  375. "images": [image_b64],
  376. "stream": False
  377. })
  378. return response.json().get("response", "Impossible d'analyser l'image.")
  379. except Exception as e:
  380. return f"Erreur analyse image : {e}"
  381. # ─── TTS / STT ────────────────────────────────────────────────────────────────
  382. logger.info("Chargement Whisper...")
  383. whisper_model = whisper.load_model("tiny")
  384. logger.info("Whisper pret.")
  385. def text_to_audio(text):
  386. tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
  387. tmp.close()
  388. subprocess.run(
  389. [PIPER_BIN, "--model", VOICE_MODEL, "--output_file", tmp.name],
  390. input=text.encode("utf-8"), check=True
  391. )
  392. return tmp.name
  393. async def transcribe_voice(file_path):
  394. result = whisper_model.transcribe(file_path, language="fr")
  395. return result["text"].strip()
  396. # ─── Git ──────────────────────────────────────────────────────────────────────
  397. def list_repos():
  398. if not os.path.exists(REPOS_DIR):
  399. return []
  400. return [d for d in os.listdir(REPOS_DIR) if os.path.isdir(os.path.join(REPOS_DIR, d, ".git"))]
  401. def list_files(repo):
  402. repo_path = os.path.join(REPOS_DIR, repo)
  403. if not os.path.exists(repo_path):
  404. return None
  405. result = subprocess.run(["git", "ls-files"], cwd=repo_path, capture_output=True, text=True)
  406. return [f for f in result.stdout.strip().split("\n") if f]
  407. def read_file(repo, filepath):
  408. full_path = os.path.join(REPOS_DIR, repo, filepath)
  409. if not os.path.exists(full_path):
  410. return None
  411. with open(full_path, "r", errors="replace") as f:
  412. return f.read()
  413. def git_log(repo, n=5):
  414. result = subprocess.run(["git", "log", f"-{n}", "--oneline"],
  415. cwd=os.path.join(REPOS_DIR, repo), capture_output=True, text=True)
  416. return result.stdout.strip()
  417. def git_pull(repo):
  418. result = subprocess.run(["git", "pull"],
  419. cwd=os.path.join(REPOS_DIR, repo), capture_output=True, text=True)
  420. return result.stdout.strip() or result.stderr.strip()
  421. # ─── Google Auth ──────────────────────────────────────────────────────────────
  422. def get_google_creds():
  423. creds = None
  424. if os.path.exists(GOOGLE_TOKEN_FILE):
  425. creds = Credentials.from_authorized_user_file(GOOGLE_TOKEN_FILE, GOOGLE_SCOPES)
  426. if creds and creds.expired and creds.refresh_token:
  427. creds.refresh(Request())
  428. with open(GOOGLE_TOKEN_FILE, "w") as f:
  429. f.write(creds.to_json())
  430. return creds if (creds and creds.valid) else None
  431. # ─── Google Calendar ──────────────────────────────────────────────────────────
  432. def get_calendar_events(max_results=10):
  433. creds = get_google_creds()
  434. if not creds:
  435. return None
  436. service = build("calendar", "v3", credentials=creds)
  437. now = datetime.utcnow().isoformat() + "Z"
  438. events_result = service.events().list(
  439. calendarId="primary",
  440. timeMin=now,
  441. maxResults=max_results,
  442. singleEvents=True,
  443. orderBy="startTime"
  444. ).execute()
  445. return events_result.get("items", [])
  446. # ─── Gmail ────────────────────────────────────────────────────────────────────
  447. def get_gmail_messages(max_results=5):
  448. creds = get_google_creds()
  449. if not creds:
  450. return None
  451. service = build("gmail", "v1", credentials=creds)
  452. result = service.users().messages().list(
  453. userId="me", maxResults=max_results, q="is:unread"
  454. ).execute()
  455. messages = result.get("messages", [])
  456. emails = []
  457. for msg in messages:
  458. detail = service.users().messages().get(
  459. userId="me", id=msg["id"], format="metadata",
  460. metadataHeaders=["From", "Subject", "Date"]
  461. ).execute()
  462. headers = {h["name"]: h["value"] for h in detail["payload"]["headers"]}
  463. emails.append({
  464. "from": headers.get("From", ""),
  465. "subject": headers.get("Subject", ""),
  466. "date": headers.get("Date", "")
  467. })
  468. return emails
  469. # ─── Notes ────────────────────────────────────────────────────────────────────
  470. def notes_path(user_id):
  471. return os.path.join(MEMORY_DIR, f"{user_id}_notes.json")
  472. def load_notes(user_id):
  473. path = notes_path(user_id)
  474. if os.path.exists(path):
  475. with open(path, "r") as f:
  476. return json.load(f)
  477. return []
  478. def save_notes(user_id, notes):
  479. with open(notes_path(user_id), "w") as f:
  480. json.dump(notes, f, ensure_ascii=False, indent=2)
  481. def add_note(user_id, content):
  482. notes = load_notes(user_id)
  483. note = {
  484. "id": len(notes) + 1,
  485. "content": content,
  486. "date": datetime.now().strftime("%Y-%m-%d %H:%M")
  487. }
  488. notes.append(note)
  489. save_notes(user_id, notes)
  490. return note
  491. def search_notes(user_id, query):
  492. notes = load_notes(user_id)
  493. query_lower = query.lower()
  494. return [n for n in notes if query_lower in n["content"].lower()]
  495. def search_memory(user_id, query):
  496. results = []
  497. query_lower = query.lower()
  498. # Chercher dans toutes les conversations
  499. for agent in AGENTS:
  500. path = memory_path(user_id, agent)
  501. if not os.path.exists(path):
  502. continue
  503. msgs = json.load(open(path))
  504. for msg in msgs:
  505. if msg["role"] in ("user", "assistant") and query_lower in msg["content"].lower():
  506. results.append({
  507. "agent": agent,
  508. "role": msg["role"],
  509. "content": msg["content"][:300]
  510. })
  511. # Chercher dans les notes
  512. notes = search_notes(user_id, query)
  513. return results, notes
  514. # ─── Météo ────────────────────────────────────────────────────────────────────
  515. async def get_weather(city: str) -> str:
  516. try:
  517. async with httpx.AsyncClient(timeout=10) as client:
  518. response = await client.get(f"https://wttr.in/{city}?format=3&lang=fr")
  519. return f"🌤️ {response.text.strip()}"
  520. except Exception as e:
  521. return f"Impossible de récupérer la météo : {e}"
  522. # ─── Recherche web ────────────────────────────────────────────────────────────
  523. def web_search(query: str, max_results: int = 5) -> str:
  524. try:
  525. with DDGS() as ddgs:
  526. results = list(ddgs.text(query, region="fr-fr", max_results=max_results))
  527. if not results:
  528. return "Aucun résultat trouvé."
  529. msg = ""
  530. for r in results:
  531. msg += f"• *{r.get('title','')}*\n{r.get('body','')[:200]}\n\n"
  532. return msg.strip()
  533. except Exception as e:
  534. return f"Erreur recherche : {e}"
  535. # ─── Actualités ───────────────────────────────────────────────────────────────
  536. def get_news(category: str = "tech", max_items: int = 5) -> str:
  537. feeds = NEWS_FEEDS.get(category, NEWS_FEEDS["tech"])
  538. items = []
  539. for url in feeds:
  540. try:
  541. feed = feedparser.parse(url)
  542. for entry in feed.entries[:3]:
  543. items.append({
  544. "title": entry.get("title", ""),
  545. "summary": entry.get("summary", "")[:200]
  546. })
  547. except Exception:
  548. continue
  549. if not items:
  550. return "Impossible de récupérer les actualités."
  551. items = items[:max_items]
  552. msg = f"📰 *Actualités {category}*\n\n"
  553. for item in items:
  554. msg += f"• *{item['title']}*\n{item['summary']}\n\n"
  555. return msg.strip()
  556. # ─── Handlers ─────────────────────────────────────────────────────────────────
  557. async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
  558. user_id = update.effective_user.id
  559. profile = load_profile(user_id)
  560. nom = profile.get("nom")
  561. if nom:
  562. msg = f"Content de te retrouver {nom} ! Je suis Konan."
  563. else:
  564. msg = (
  565. "Bonjour ! Je suis Konan, ton orchestrateur d'agents IA.\n\n"
  566. "Agents : 💻 Coder · 🔍 Reviewer · 🏗️ Architect · 📊 Analyst\n"
  567. "Tu peux m'envoyer du texte, de la voix, des images ou ta localisation.\n\n"
  568. "/setnom Prenom — te présenter\n"
  569. "/monitoring — état du serveur\n"
  570. "/meteo [ville] — météo (ou partage ta position)\n"
  571. "/news [tech|politique|football] — actualités\n"
  572. "/recherche <query> — recherche web\n"
  573. "/note <contenu> — sauvegarder une note\n"
  574. "/notes — voir tes notes\n"
  575. "/chercher <mot> — chercher dans la mémoire\n"
  576. "/briefing 08:00 — briefing matinal automatique\n"
  577. "/rappel 30m Message — créer un rappel\n"
  578. "/repos — projets Git\n"
  579. "/agents — voir les agents\n"
  580. "/reset — réinitialiser"
  581. )
  582. await update.message.reply_text(msg)
  583. async def monitoring_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  584. status = get_server_status()
  585. await update.message.reply_text(status, parse_mode="Markdown")
  586. async def rappel_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  587. user_id = update.effective_user.id
  588. if len(context.args) < 2:
  589. await update.message.reply_text(
  590. "Usage : /rappel <temps> <message>\n\n"
  591. "Exemples :\n"
  592. "/rappel 30m Faire une pause\n"
  593. "/rappel 2h Revoir l'architecture\n"
  594. "/rappel 14:30 Réunion équipe"
  595. )
  596. return
  597. time_str = context.args[0]
  598. message = " ".join(context.args[1:])
  599. target_time = parse_time(time_str)
  600. if not target_time:
  601. await update.message.reply_text("Format de temps non reconnu. Exemples : 30m, 2h, 14:30")
  602. return
  603. reminder_id = f"{user_id}_{int(target_time.timestamp())}"
  604. bot = context.application.bot
  605. scheduler.add_job(
  606. send_reminder,
  607. "date",
  608. run_date=target_time,
  609. args=[bot, user_id, message, reminder_id],
  610. id=reminder_id
  611. )
  612. reminders = load_reminders()
  613. reminders.append({
  614. "id": reminder_id,
  615. "user_id": user_id,
  616. "message": message,
  617. "time": target_time.isoformat()
  618. })
  619. save_reminders(reminders)
  620. heure = target_time.strftime("%H:%M")
  621. await update.message.reply_text(f"⏰ Rappel créé pour {heure} : \"{message}\"")
  622. async def rappels_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  623. user_id = update.effective_user.id
  624. reminders = [r for r in load_reminders() if r["user_id"] == user_id]
  625. if not reminders:
  626. await update.message.reply_text("Aucun rappel en attente.")
  627. return
  628. msg = "⏰ Tes rappels :\n\n"
  629. for r in reminders:
  630. t = datetime.fromisoformat(r["time"]).strftime("%H:%M")
  631. msg += f"• {t} — {r['message']}\n"
  632. await update.message.reply_text(msg)
  633. async def agents_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  634. msg = "Agents disponibles :\n\n"
  635. for name, cfg in AGENTS.items():
  636. emoji = AGENT_EMOJIS.get(name, "🤖")
  637. msg += f"{emoji} *{name.capitalize()}* — {cfg['model']}\n"
  638. await update.message.reply_text(msg, parse_mode="Markdown")
  639. async def reset(update: Update, context: ContextTypes.DEFAULT_TYPE):
  640. user_id = update.effective_user.id
  641. for agent in AGENTS:
  642. path = memory_path(user_id, agent)
  643. if os.path.exists(path):
  644. os.remove(path)
  645. await update.message.reply_text("Mémoire réinitialisée. Profil conservé.")
  646. async def set_nom(update: Update, context: ContextTypes.DEFAULT_TYPE):
  647. user_id = update.effective_user.id
  648. if not context.args:
  649. await update.message.reply_text("Usage : /setnom Prenom")
  650. return
  651. nom = " ".join(context.args)
  652. profile = load_profile(user_id)
  653. profile["nom"] = nom
  654. save_profile(user_id, profile)
  655. await update.message.reply_text(f"Parfait, je m'en souviendrai {nom} !")
  656. async def set_projet(update: Update, context: ContextTypes.DEFAULT_TYPE):
  657. user_id = update.effective_user.id
  658. if not context.args:
  659. await update.message.reply_text("Usage : /setprojet Nom")
  660. return
  661. projet = " ".join(context.args)
  662. profile = load_profile(user_id)
  663. if projet not in profile["projets"]:
  664. profile["projets"].append(projet)
  665. save_profile(user_id, profile)
  666. await update.message.reply_text(f"Projet \"{projet}\" ajouté.")
  667. async def profil(update: Update, context: ContextTypes.DEFAULT_TYPE):
  668. user_id = update.effective_user.id
  669. profile = load_profile(user_id)
  670. nom = profile.get("nom") or "non défini"
  671. projets = ", ".join(profile.get("projets", [])) or "aucun"
  672. await update.message.reply_text(f"Nom : {nom}\nProjets : {projets}")
  673. async def repos_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  674. repos = list_repos()
  675. if not repos:
  676. await update.message.reply_text("Aucun repo cloné.\n/clone <url> pour en ajouter un.")
  677. return
  678. msg = "Repos disponibles :\n\n"
  679. for r in repos:
  680. msg += f"📁 {r}\n"
  681. msg += "\n/fichiers <repo> · /log <repo> · /analyser <repo> <fichier>"
  682. await update.message.reply_text(msg)
  683. async def clone_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  684. if not context.args:
  685. await update.message.reply_text("Usage : /clone <url>")
  686. return
  687. url = context.args[0]
  688. name = url.rstrip("/").split("/")[-1].replace(".git", "")
  689. dest = os.path.join(REPOS_DIR, name)
  690. await update.message.reply_text(f"Clonage de {name}...")
  691. result = subprocess.run(["git", "clone", url, dest], capture_output=True, text=True)
  692. if result.returncode == 0:
  693. await update.message.reply_text(f"✅ {name} cloné.")
  694. else:
  695. await update.message.reply_text(f"Erreur : {result.stderr}")
  696. async def fichiers_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  697. if not context.args:
  698. await update.message.reply_text("Usage : /fichiers <repo>")
  699. return
  700. repo = context.args[0]
  701. files = list_files(repo)
  702. if files is None:
  703. await update.message.reply_text(f"Repo '{repo}' introuvable.")
  704. return
  705. msg = f"Fichiers de {repo} :\n\n" + "\n".join(files[:50])
  706. if len(files) > 50:
  707. msg += f"\n... et {len(files) - 50} autres"
  708. await update.message.reply_text(msg)
  709. async def log_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  710. if not context.args:
  711. await update.message.reply_text("Usage : /log <repo>")
  712. return
  713. repo = context.args[0]
  714. log = git_log(repo)
  715. await update.message.reply_text(f"Derniers commits de {repo} :\n\n{log}" if log else "Repo introuvable.")
  716. async def lire_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  717. if len(context.args) < 2:
  718. await update.message.reply_text("Usage : /lire <repo> <fichier>")
  719. return
  720. repo = context.args[0]
  721. filepath = " ".join(context.args[1:])
  722. content = read_file(repo, filepath)
  723. if content is None:
  724. await update.message.reply_text(f"Fichier '{filepath}' introuvable.")
  725. return
  726. if len(content) > 3000:
  727. content = content[:3000] + "\n... (tronqué)"
  728. await update.message.reply_text(f"```\n{content}\n```", parse_mode="Markdown")
  729. async def analyser_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  730. user_id = update.effective_user.id
  731. if len(context.args) < 2:
  732. await update.message.reply_text("Usage : /analyser <repo> <fichier>")
  733. return
  734. repo = context.args[0]
  735. filepath = " ".join(context.args[1:])
  736. content = read_file(repo, filepath)
  737. if content is None:
  738. await update.message.reply_text(f"Fichier '{filepath}' introuvable.")
  739. return
  740. await update.message.reply_text(f"🔍 Analyse de {filepath}...")
  741. message = f"Analyse ce fichier et donne résumé, points forts et problèmes :\n\n```\n{content[:4000]}\n```"
  742. reply, _ = await ask_agent(user_id, message, "reviewer")
  743. await update.message.reply_text(reply)
  744. async def pull_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  745. if not context.args:
  746. await update.message.reply_text("Usage : /pull <repo>")
  747. return
  748. result = git_pull(context.args[0])
  749. await update.message.reply_text(result)
  750. async def google_connect_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  751. user_id = update.effective_user.id
  752. if get_google_creds():
  753. await update.message.reply_text("✅ Google déjà connecté.\n/calendrier — voir tes événements\n/mails — voir tes mails")
  754. return
  755. flow = Flow.from_client_secrets_file(
  756. GOOGLE_CREDS_FILE,
  757. scopes=GOOGLE_SCOPES,
  758. redirect_uri="urn:ietf:wg:oauth:2.0:oob"
  759. )
  760. auth_url, _ = flow.authorization_url(prompt="consent")
  761. pending_oauth[user_id] = flow
  762. await update.message.reply_text(
  763. "🔗 Connexion Google\n\n"
  764. "1. Clique sur ce lien et autorise l'accès :\n"
  765. f"{auth_url}\n\n"
  766. "2. Copie le code affiché\n"
  767. "3. Envoie-le moi avec /google_code LECODICI"
  768. )
  769. async def google_code_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  770. user_id = update.effective_user.id
  771. if not context.args:
  772. await update.message.reply_text("Usage : /google_code <code>")
  773. return
  774. if user_id not in pending_oauth:
  775. await update.message.reply_text("Lance d'abord /google_connect")
  776. return
  777. code = context.args[0]
  778. flow = pending_oauth[user_id]
  779. try:
  780. flow.fetch_token(code=code)
  781. creds = flow.credentials
  782. with open(GOOGLE_TOKEN_FILE, "w") as f:
  783. f.write(creds.to_json())
  784. del pending_oauth[user_id]
  785. await update.message.reply_text(
  786. "✅ Google connecté avec succès !\n\n"
  787. "/calendrier — voir tes événements\n"
  788. "/mails — voir tes mails non lus"
  789. )
  790. except Exception as e:
  791. await update.message.reply_text(f"Erreur d'authentification : {e}")
  792. async def calendrier_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  793. await update.message.reply_text("📅 Chargement du calendrier...")
  794. events = get_calendar_events()
  795. if events is None:
  796. await update.message.reply_text("Google non connecté. Lance /google_connect")
  797. return
  798. if not events:
  799. await update.message.reply_text("Aucun événement à venir.")
  800. return
  801. msg = "📅 *Prochains événements :*\n\n"
  802. for event in events:
  803. start = event["start"].get("dateTime", event["start"].get("date", ""))
  804. if "T" in start:
  805. dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
  806. start_str = dt.strftime("%d/%m %H:%M")
  807. else:
  808. start_str = start
  809. msg += f"• *{event.get('summary', 'Sans titre')}*\n 📅 {start_str}\n\n"
  810. await update.message.reply_text(msg, parse_mode="Markdown")
  811. async def mails_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  812. await update.message.reply_text("📬 Chargement des mails...")
  813. emails = get_gmail_messages()
  814. if emails is None:
  815. await update.message.reply_text("Google non connecté. Lance /google_connect")
  816. return
  817. if not emails:
  818. await update.message.reply_text("Aucun mail non lu.")
  819. return
  820. msg = "📬 *Mails non lus :*\n\n"
  821. for e in emails:
  822. msg += f"• *{e['subject']}*\n De : {e['from'][:50]}\n\n"
  823. await update.message.reply_text(msg, parse_mode="Markdown")
  824. async def briefing_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  825. user_id = update.effective_user.id
  826. if not context.args:
  827. briefings = load_briefing_config()
  828. cfg = briefings.get(str(user_id))
  829. if cfg:
  830. await update.message.reply_text(
  831. f"⏰ Briefing matinal configuré à {cfg['heure']}.\n\n"
  832. f"/briefing 07:30 — changer l'heure\n"
  833. f"/briefing stop — désactiver\n"
  834. f"/briefing_test — recevoir un briefing maintenant"
  835. )
  836. else:
  837. await update.message.reply_text(
  838. "Aucun briefing configuré.\n\n"
  839. "Usage : /briefing 08:00\n"
  840. "/briefing_test — tester maintenant"
  841. )
  842. return
  843. if context.args[0].lower() == "stop":
  844. briefings = load_briefing_config()
  845. briefings.pop(str(user_id), None)
  846. save_briefing_config(briefings)
  847. job_id = f"briefing_{user_id}"
  848. if scheduler.get_job(job_id):
  849. scheduler.remove_job(job_id)
  850. await update.message.reply_text("Briefing matinal désactivé.")
  851. return
  852. heure = context.args[0]
  853. if ":" not in heure:
  854. await update.message.reply_text("Format : /briefing 08:00")
  855. return
  856. h, m = heure.split(":")
  857. briefings = load_briefing_config()
  858. briefings[str(user_id)] = {"heure": heure}
  859. save_briefing_config(briefings)
  860. bot = context.application.bot
  861. job_id = f"briefing_{user_id}"
  862. if scheduler.get_job(job_id):
  863. scheduler.remove_job(job_id)
  864. scheduler.add_job(
  865. send_briefing,
  866. "cron",
  867. hour=int(h),
  868. minute=int(m),
  869. args=[bot, user_id],
  870. id=job_id,
  871. replace_existing=True
  872. )
  873. await update.message.reply_text(
  874. f"✅ Briefing matinal configuré à {heure}.\n"
  875. f"Chaque matin tu recevras : météo, news tech, foot, tes notes et l'état du serveur.\n\n"
  876. f"/briefing_test — tester maintenant"
  877. )
  878. async def briefing_test_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  879. user_id = update.effective_user.id
  880. await update.message.reply_text("Génération du briefing...")
  881. bot = context.application.bot
  882. await send_briefing(bot, user_id)
  883. async def note_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  884. user_id = update.effective_user.id
  885. if not context.args:
  886. await update.message.reply_text("Usage : /note <contenu>\nEx: /note Je préfère Rust pour le backend")
  887. return
  888. content = " ".join(context.args)
  889. note = add_note(user_id, content)
  890. await update.message.reply_text(f"📝 Note #{note['id']} sauvegardée.")
  891. async def notes_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  892. user_id = update.effective_user.id
  893. notes = load_notes(user_id)
  894. if not notes:
  895. await update.message.reply_text("Aucune note. Utilise /note <contenu> pour en créer une.")
  896. return
  897. msg = "📝 *Tes notes :*\n\n"
  898. for n in notes[-10:]:
  899. msg += f"#{n['id']} [{n['date']}]\n{n['content']}\n\n"
  900. await update.message.reply_text(msg, parse_mode="Markdown")
  901. async def chercher_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  902. user_id = update.effective_user.id
  903. if not context.args:
  904. await update.message.reply_text("Usage : /chercher <mot-clé>")
  905. return
  906. query = " ".join(context.args)
  907. await update.message.reply_text(f"🔍 Recherche dans la mémoire : \"{query}\"...")
  908. memory_results, note_results = search_memory(user_id, query)
  909. msg = ""
  910. if note_results:
  911. msg += f"📝 *Notes ({len(note_results)}) :*\n"
  912. for n in note_results[:3]:
  913. msg += f"• [{n['date']}] {n['content']}\n"
  914. msg += "\n"
  915. if memory_results:
  916. msg += f"💬 *Conversations ({len(memory_results)}) :*\n"
  917. for r in memory_results[:5]:
  918. role_icon = "👤" if r["role"] == "user" else "🤖"
  919. msg += f"{role_icon} [{r['agent']}] {r['content'][:200]}\n\n"
  920. if not msg:
  921. msg = f"Aucun résultat pour \"{query}\" dans la mémoire ou les notes."
  922. await update.message.reply_text(msg, parse_mode="Markdown")
  923. async def meteo_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  924. user_id = update.effective_user.id
  925. profile = load_profile(user_id)
  926. city = " ".join(context.args) if context.args else profile.get("ville", DEFAULT_CITY)
  927. result = await get_weather(city)
  928. await update.message.reply_text(result)
  929. async def recherche_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  930. user_id = update.effective_user.id
  931. if not context.args:
  932. await update.message.reply_text("Usage : /recherche <requête>")
  933. return
  934. query = " ".join(context.args)
  935. await update.message.reply_text(f"🔍 Recherche : {query}...")
  936. results = web_search(query)
  937. await update.message.reply_text(results, parse_mode="Markdown")
  938. async def news_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
  939. category = context.args[0].lower() if context.args else "tech"
  940. if category not in NEWS_FEEDS:
  941. await update.message.reply_text(f"Catégories disponibles : {', '.join(NEWS_FEEDS.keys())}")
  942. return
  943. await update.message.reply_text(f"📰 Chargement des news {category}...")
  944. news = get_news(category)
  945. await update.message.reply_text(news, parse_mode="Markdown")
  946. async def handle_location(update: Update, context: ContextTypes.DEFAULT_TYPE):
  947. user_id = update.effective_user.id
  948. location = update.message.location
  949. lat, lon = location.latitude, location.longitude
  950. await update.message.reply_text("📍 Localisation reçue, récupération météo...")
  951. try:
  952. async with httpx.AsyncClient(timeout=10) as client:
  953. response = await client.get(f"https://wttr.in/{lat},{lon}?format=3&lang=fr")
  954. result = f"🌤️ {response.text.strip()}"
  955. # Sauvegarder la ville dans le profil
  956. city_resp = await client.get(f"https://wttr.in/{lat},{lon}?format=%l")
  957. city = city_resp.text.strip()
  958. profile = load_profile(user_id)
  959. profile["ville"] = f"{lat},{lon}"
  960. save_profile(user_id, profile)
  961. await update.message.reply_text(f"{result}\n\n📍 Position sauvegardée pour les prochaines fois.")
  962. except Exception as e:
  963. await update.message.reply_text(f"Erreur météo : {e}")
  964. async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
  965. user_id = update.effective_user.id
  966. text = update.message.text
  967. # Détection automatique de note
  968. note_triggers = ["note que", "souviens-toi que", "retiens que", "n'oublie pas que", "mémorise que"]
  969. text_lower = text.lower()
  970. for trigger in note_triggers:
  971. if text_lower.startswith(trigger):
  972. content = text[len(trigger):].strip()
  973. note = add_note(user_id, content)
  974. await update.message.reply_text(f"📝 Noté ! (#{note['id']}) : \"{content}\"")
  975. return
  976. await update.message.reply_text("⏳")
  977. intent_data = await detect_intent(text)
  978. if await execute_intent(intent_data, user_id, update, context):
  979. return
  980. agent = await detect_agent(text)
  981. emoji = AGENT_EMOJIS.get(agent, "🤖")
  982. await update.message.reply_text(f"{emoji} Agent {agent.capitalize()}...")
  983. reply, _ = await ask_agent(user_id, text, agent)
  984. await update.message.reply_text(reply)
  985. async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
  986. user_id = update.effective_user.id
  987. await update.message.reply_text("🎤 Transcription...")
  988. voice = update.message.voice
  989. file = await context.bot.get_file(voice.file_id)
  990. with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
  991. tmp_path = tmp.name
  992. await file.download_to_drive(tmp_path)
  993. try:
  994. text = await transcribe_voice(tmp_path)
  995. await update.message.reply_text(f"« {text} »")
  996. intent_data = await detect_intent(text)
  997. if not await execute_intent(intent_data, user_id, update, context):
  998. agent = await detect_agent(text)
  999. emoji = AGENT_EMOJIS.get(agent, "🤖")
  1000. await update.message.reply_text(f"{emoji} Agent {agent.capitalize()}...")
  1001. reply, _ = await ask_agent(user_id, text, agent)
  1002. await update.message.reply_text(reply)
  1003. audio_path = text_to_audio(reply)
  1004. with open(audio_path, "rb") as audio_file:
  1005. await update.message.reply_voice(audio_file)
  1006. os.unlink(audio_path)
  1007. except Exception as e:
  1008. await update.message.reply_text(f"Erreur : {e}")
  1009. finally:
  1010. os.unlink(tmp_path)
  1011. async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
  1012. await update.message.reply_text("🖼️ Analyse de l'image...")
  1013. photo = update.message.photo[-1]
  1014. file = await context.bot.get_file(photo.file_id)
  1015. with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
  1016. tmp_path = tmp.name
  1017. await file.download_to_drive(tmp_path)
  1018. try:
  1019. caption = update.message.caption or "Décris cette image en détail en français."
  1020. result = await analyze_image(tmp_path, caption)
  1021. await update.message.reply_text(result)
  1022. except Exception as e:
  1023. await update.message.reply_text(f"Erreur : {e}")
  1024. finally:
  1025. os.unlink(tmp_path)
  1026. # ─── Main ─────────────────────────────────────────────────────────────────────
  1027. BRIEFING_FILE = os.path.join(os.path.expanduser("~/agent"), "briefing.json")
  1028. GOOGLE_CREDS_FILE = os.path.expanduser("~/agent/google_credentials.json")
  1029. GOOGLE_TOKEN_FILE = os.path.expanduser("~/agent/google_token.json")
  1030. GOOGLE_SCOPES = [
  1031. "https://www.googleapis.com/auth/calendar.readonly",
  1032. "https://www.googleapis.com/auth/gmail.readonly"
  1033. ]
  1034. # Stockage temporaire des flows OAuth en attente
  1035. pending_oauth = {}
  1036. def load_briefing_config():
  1037. if os.path.exists(BRIEFING_FILE):
  1038. with open(BRIEFING_FILE, "r") as f:
  1039. return json.load(f)
  1040. return {}
  1041. def save_briefing_config(config):
  1042. with open(BRIEFING_FILE, "w") as f:
  1043. json.dump(config, f, ensure_ascii=False, indent=2)
  1044. async def send_briefing(bot: Bot, user_id: int):
  1045. try:
  1046. profile = load_profile(user_id)
  1047. nom = profile.get("nom", "")
  1048. ville = profile.get("ville", DEFAULT_CITY)
  1049. notes = load_notes(user_id)
  1050. notes_recentes = notes[-3:] if notes else []
  1051. greeting = f"☀️ Bonjour {nom} !\n\n" if nom else "☀️ Bonjour !\n\n"
  1052. msg = greeting
  1053. # Météo
  1054. meteo = await get_weather(ville)
  1055. msg += f"{meteo}\n\n"
  1056. # News tech
  1057. tech = get_news("tech", max_items=3)
  1058. msg += f"📰 *Tech du jour :*\n"
  1059. lines = [l for l in tech.split("\n") if l.startswith("•")][:3]
  1060. msg += "\n".join(lines) + "\n\n"
  1061. # Football
  1062. foot = get_news("football", max_items=2)
  1063. msg += f"⚽ *Football :*\n"
  1064. lines = [l for l in foot.split("\n") if l.startswith("•")][:2]
  1065. msg += "\n".join(lines) + "\n\n"
  1066. # Notes récentes
  1067. if notes_recentes:
  1068. msg += "📝 *Tes dernières notes :*\n"
  1069. for n in notes_recentes:
  1070. msg += f"• {n['content']}\n"
  1071. msg += "\n"
  1072. # Calendrier
  1073. events = get_calendar_events(max_results=3)
  1074. if events:
  1075. msg += "📅 *Agenda du jour :*\n"
  1076. for event in events:
  1077. start = event["start"].get("dateTime", event["start"].get("date", ""))
  1078. if "T" in start:
  1079. dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
  1080. start_str = dt.strftime("%H:%M")
  1081. else:
  1082. start_str = start
  1083. msg += f"• {start_str} — {event.get('summary', 'Sans titre')}\n"
  1084. msg += "\n"
  1085. # Mails non lus
  1086. emails = get_gmail_messages(max_results=3)
  1087. if emails:
  1088. msg += f"📬 *{len(emails)} mail(s) non lu(s) :*\n"
  1089. for e in emails[:3]:
  1090. msg += f"• {e['subject'][:50]}\n"
  1091. msg += "\n"
  1092. # Monitoring
  1093. cpu = psutil.cpu_percent(interval=0.5)
  1094. ram = psutil.virtual_memory()
  1095. msg += f"🖥️ Serveur : CPU {cpu:.0f}% · RAM {ram.percent:.0f}%"
  1096. await bot.send_message(chat_id=user_id, text=msg, parse_mode="Markdown")
  1097. logger.info(f"Briefing envoyé à {user_id}")
  1098. except Exception as e:
  1099. logger.error(f"Erreur briefing : {e}")
  1100. async def post_init(application):
  1101. scheduler.start()
  1102. bot = application.bot
  1103. # Recharger les rappels persistés
  1104. reminders = load_reminders()
  1105. for r in reminders:
  1106. target = datetime.fromisoformat(r["time"])
  1107. if target > datetime.now():
  1108. scheduler.add_job(
  1109. send_reminder,
  1110. "date",
  1111. run_date=target,
  1112. args=[bot, r["user_id"], r["message"], r["id"]],
  1113. id=r["id"],
  1114. replace_existing=True
  1115. )
  1116. logger.info(f"{len(reminders)} rappel(s) rechargé(s).")
  1117. # Recharger les briefings configurés
  1118. briefings = load_briefing_config()
  1119. for user_id_str, cfg in briefings.items():
  1120. user_id = int(user_id_str)
  1121. heure = cfg.get("heure", "08:00")
  1122. h, m = heure.split(":")
  1123. scheduler.add_job(
  1124. send_briefing,
  1125. "cron",
  1126. hour=int(h),
  1127. minute=int(m),
  1128. args=[bot, user_id],
  1129. id=f"briefing_{user_id}",
  1130. replace_existing=True
  1131. )
  1132. logger.info(f"Briefing programmé à {heure} pour {user_id}")
  1133. # Démarrer l'API HTTP pour le client PC
  1134. api_app = web.Application()
  1135. api_app.router.add_post("/api/chat", api_chat_handler)
  1136. runner = web.AppRunner(api_app)
  1137. await runner.setup()
  1138. site = web.TCPSite(runner, "0.0.0.0", 8765)
  1139. await site.start()
  1140. logger.info("API HTTP démarrée sur port 8765")
  1141. async def api_chat_handler(request):
  1142. try:
  1143. data = await request.json()
  1144. text = data.get("text", "").strip()
  1145. user_id = data.get("user_id", "pc_user")
  1146. if not text:
  1147. return web.json_response({"error": "texte vide"}, status=400)
  1148. agent = await detect_agent(text)
  1149. response = await ask_agent(user_id, text, agent)
  1150. return web.json_response({"response": response, "agent": agent})
  1151. except Exception as e:
  1152. logger.error(f"API erreur : {e}")
  1153. return web.json_response({"error": str(e)}, status=500)
  1154. if __name__ == "__main__":
  1155. app = ApplicationBuilder().token(TELEGRAM_TOKEN).post_init(post_init).build()
  1156. app.add_handler(CommandHandler("start", start))
  1157. app.add_handler(CommandHandler("reset", reset))
  1158. app.add_handler(CommandHandler("agents", agents_cmd))
  1159. app.add_handler(CommandHandler("setnom", set_nom))
  1160. app.add_handler(CommandHandler("setprojet", set_projet))
  1161. app.add_handler(CommandHandler("profil", profil))
  1162. app.add_handler(CommandHandler("monitoring", monitoring_cmd))
  1163. app.add_handler(CommandHandler("rappel", rappel_cmd))
  1164. app.add_handler(CommandHandler("rappels", rappels_cmd))
  1165. app.add_handler(CommandHandler("repos", repos_cmd))
  1166. app.add_handler(CommandHandler("clone", clone_cmd))
  1167. app.add_handler(CommandHandler("fichiers", fichiers_cmd))
  1168. app.add_handler(CommandHandler("lire", lire_cmd))
  1169. app.add_handler(CommandHandler("analyser", analyser_cmd))
  1170. app.add_handler(CommandHandler("log", log_cmd))
  1171. app.add_handler(CommandHandler("pull", pull_cmd))
  1172. app.add_handler(CommandHandler("briefing", briefing_cmd))
  1173. app.add_handler(CommandHandler("briefing_test", briefing_test_cmd))
  1174. app.add_handler(CommandHandler("note", note_cmd))
  1175. app.add_handler(CommandHandler("notes", notes_cmd))
  1176. app.add_handler(CommandHandler("chercher", chercher_cmd))
  1177. app.add_handler(CommandHandler("meteo", meteo_cmd))
  1178. app.add_handler(CommandHandler("recherche", recherche_cmd))
  1179. app.add_handler(CommandHandler("news", news_cmd))
  1180. app.add_handler(CommandHandler("google_connect", google_connect_cmd))
  1181. app.add_handler(CommandHandler("google_code", google_code_cmd))
  1182. app.add_handler(CommandHandler("calendrier", calendrier_cmd))
  1183. app.add_handler(CommandHandler("mails", mails_cmd))
  1184. app.add_handler(MessageHandler(filters.LOCATION, handle_location))
  1185. app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
  1186. app.add_handler(MessageHandler(filters.VOICE, handle_voice))
  1187. app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
  1188. logger.info("Konan demarre - Google + Météo + News + Recherche + Monitoring + Rappels + Git + Vision + Voix + Agents.")
  1189. app.run_polling()