chatbot.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  1. import os
  2. import json
  3. import logging
  4. import base64
  5. from io import BytesIO
  6. import asyncio
  7. import mysql.connector
  8. import pytz
  9. from mysql.connector import Error
  10. from PIL import Image
  11. import tiktoken
  12. import discord
  13. from discord.ext import commands
  14. from discord import app_commands
  15. from datetime import datetime, timezone
  16. from dotenv import load_dotenv
  17. from openai import AsyncOpenAI, OpenAIError
  18. # =================================
  19. # Configuration et Initialisation
  20. # =================================
  21. # Charger les variables d'environnement depuis le fichier .env
  22. load_dotenv()
  23. DISCORD_TOKEN = os.getenv('DISCORD_TOKEN')
  24. OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
  25. DISCORD_CHANNEL_ID = os.getenv('DISCORD_CHANNEL_ID')
  26. PERSONALITY_PROMPT_FILE = os.getenv('PERSONALITY_PROMPT_FILE', 'personality_prompt.txt')
  27. IMAGE_ANALYSIS_PROMPT_FILE = os.getenv('IMAGE_ANALYSIS_PROMPT_FILE', 'image_analysis_prompt.txt')
  28. BOT_NAME = os.getenv('BOT_NAME', 'ChatBot')
  29. BOT_VERSION = "2.8.0"
  30. # Validation des variables d'environnement
  31. required_env_vars = {
  32. 'DISCORD_TOKEN': DISCORD_TOKEN,
  33. 'OPENAI_API_KEY': OPENAI_API_KEY,
  34. 'DISCORD_CHANNEL_ID': DISCORD_CHANNEL_ID,
  35. 'IMAGE_ANALYSIS_PROMPT_FILE': IMAGE_ANALYSIS_PROMPT_FILE
  36. }
  37. missing_vars = [var for var, val in required_env_vars.items() if val is None]
  38. if missing_vars:
  39. raise ValueError(f"Les variables d'environnement suivantes ne sont pas définies: {', '.join(missing_vars)}")
  40. # Vérification de l'existence des fichiers de prompt
  41. for file_var, file_path in [('PERSONALITY_PROMPT_FILE', PERSONALITY_PROMPT_FILE),
  42. ('IMAGE_ANALYSIS_PROMPT_FILE', IMAGE_ANALYSIS_PROMPT_FILE)]:
  43. if not os.path.isfile(file_path):
  44. raise FileNotFoundError(f"Le fichier de prompt '{file_var}' '{file_path}' est introuvable.")
  45. # Lire les prompts depuis les fichiers
  46. with open(PERSONALITY_PROMPT_FILE, 'r', encoding='utf-8') as f:
  47. PERSONALITY_PROMPT = f.read().strip()
  48. with open(IMAGE_ANALYSIS_PROMPT_FILE, 'r', encoding='utf-8') as f:
  49. IMAGE_ANALYSIS_PROMPT = f.read().strip()
  50. # Configurer les logs
  51. LOG_FORMAT = '%(asctime)s : %(name)s : %(levelname)s : %(message)s'
  52. logging.basicConfig(
  53. handlers=[
  54. logging.FileHandler("./chatbot.log", mode='a', encoding='utf-8'),
  55. logging.StreamHandler()
  56. ],
  57. format=LOG_FORMAT,
  58. level=logging.INFO
  59. )
  60. logger = logging.getLogger(BOT_NAME)
  61. logger.setLevel(logging.INFO)
  62. logging.getLogger('httpx').setLevel(logging.WARNING) # Réduire le niveau de log pour 'httpx'
  63. # Initialiser les intents Discord
  64. intents = discord.Intents.default()
  65. intents.message_content = True
  66. intents.members = True
  67. # Initialiser le client OpenAI asynchrone
  68. openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY)
  69. # Convertir l'ID du canal Discord en entier
  70. try:
  71. chatgpt_channel_id = int(DISCORD_CHANNEL_ID)
  72. except ValueError:
  73. raise ValueError("L'ID du canal Discord est invalide. Assurez-vous qu'il s'agit d'un entier.")
  74. # =====================================
  75. # Gestion de la Base de Données MariaDB
  76. # =====================================
  77. class DatabaseManager:
  78. def __init__(self):
  79. self.connection = self.create_db_connection()
  80. def create_db_connection(self):
  81. try:
  82. connection = mysql.connector.connect(
  83. host=os.getenv('DB_HOST'),
  84. user=os.getenv('DB_USER'),
  85. password=os.getenv('DB_PASSWORD'),
  86. database=os.getenv('DB_NAME'),
  87. charset='utf8mb4',
  88. collation='utf8mb4_unicode_ci'
  89. )
  90. if connection.is_connected():
  91. logger.info("Connexion réussie à MariaDB")
  92. return connection
  93. except Error as e:
  94. logger.error(f"Erreur de connexion à MariaDB: {e}")
  95. return None
  96. def load_conversation_history(self):
  97. global conversation_history
  98. try:
  99. with self.connection.cursor(dictionary=True) as cursor:
  100. cursor.execute("SELECT role, content FROM conversation_history ORDER BY id ASC")
  101. rows = cursor.fetchall()
  102. conversation_history = [
  103. row for row in rows
  104. if not (row['role'] == "system" and row['content'] == PERSONALITY_PROMPT)
  105. ]
  106. logger.info("Historique chargé depuis la base de données")
  107. except Error as e:
  108. logger.error(f"Erreur lors du chargement de l'historique depuis la base de données: {e}")
  109. conversation_history = []
  110. def save_message(self, role, content):
  111. try:
  112. with self.connection.cursor() as cursor:
  113. sql = "INSERT INTO conversation_history (role, content) VALUES (%s, %s)"
  114. cursor.execute(sql, (role, json.dumps(content, ensure_ascii=False) if isinstance(content, (dict, list)) else content))
  115. self.connection.commit()
  116. logger.debug(f"Message sauvegardé dans la base de données: {role} - {content[:50]}...")
  117. except Error as e:
  118. logger.error(f"Erreur lors de la sauvegarde du message dans la base de données: {e}")
  119. def delete_old_image_analyses(self):
  120. try:
  121. with self.connection.cursor() as cursor:
  122. cursor.execute("DELETE FROM conversation_history WHERE role = 'system' AND content LIKE '__IMAGE_ANALYSIS__:%'")
  123. self.connection.commit()
  124. logger.info("Toutes les anciennes analyses d'image ont été supprimées de la base de données.")
  125. except Error as e:
  126. logger.error(f"Erreur lors de la suppression des analyses d'image: {e}")
  127. def reset_history(self):
  128. try:
  129. with self.connection.cursor() as cursor:
  130. cursor.execute("DELETE FROM conversation_history")
  131. self.connection.commit()
  132. logger.info("Historique des conversations réinitialisé.")
  133. except Error as e:
  134. logger.error(f"Erreur lors de la réinitialisation de l'historique: {e}")
  135. def delete_old_messages(self, limit):
  136. try:
  137. with self.connection.cursor() as cursor:
  138. cursor.execute("DELETE FROM conversation_history ORDER BY id ASC LIMIT %s", (limit,))
  139. self.connection.commit()
  140. logger.debug(f"{limit} messages les plus anciens ont été supprimés de la base de données pour maintenir l'historique à 150 messages.")
  141. except Error as e:
  142. logger.error(f"Erreur lors de la suppression des anciens messages: {e}")
  143. def close_connection(self):
  144. if self.connection and self.connection.is_connected():
  145. self.connection.close()
  146. logger.info("Connexion à la base de données fermée.")
  147. def add_reminder(self, user_id, channel_id, remind_at, content):
  148. try:
  149. with self.connection.cursor() as cursor:
  150. sql = """
  151. INSERT INTO reminders (user_id, channel_id, remind_at, content)
  152. VALUES (%s, %s, %s, %s)
  153. """
  154. cursor.execute(sql, (user_id, channel_id, remind_at, content))
  155. self.connection.commit()
  156. logger.info(f"Rappel ajouté pour l'utilisateur {user_id} à {remind_at}")
  157. except Error as e:
  158. logger.error(f"Erreur lors de l'ajout du rappel: {e}")
  159. def get_due_reminders(self, current_time):
  160. try:
  161. with self.connection.cursor(dictionary=True) as cursor:
  162. sql = "SELECT * FROM reminders WHERE remind_at <= %s"
  163. cursor.execute(sql, (current_time,))
  164. reminders = cursor.fetchall()
  165. return reminders
  166. except Error as e:
  167. logger.error(f"Erreur lors de la récupération des rappels: {e}")
  168. return []
  169. def delete_reminder(self, reminder_id):
  170. try:
  171. with self.connection.cursor() as cursor:
  172. sql = "DELETE FROM reminders WHERE id = %s"
  173. cursor.execute(sql, (reminder_id,))
  174. self.connection.commit()
  175. logger.info(f"Rappel ID {reminder_id} supprimé")
  176. except Error as e:
  177. logger.error(f"Erreur lors de la suppression du rappel ID {reminder_id}: {e}")
  178. # ===============================
  179. # Gestion de l'Historique des Messages
  180. # ===============================
  181. conversation_history = []
  182. last_analysis_index = None
  183. messages_since_last_analysis = 0
  184. # ====================
  185. # Fonctions Utilitaires
  186. # ====================
  187. def split_message(message, max_length=2000):
  188. """Divise un message en plusieurs segments de longueur maximale spécifiée."""
  189. if len(message) <= max_length:
  190. return [message]
  191. parts = []
  192. current_part = ""
  193. for line in message.split('\n'):
  194. if len(current_part) + len(line) + 1 > max_length:
  195. parts.append(current_part)
  196. current_part = line + '\n'
  197. else:
  198. current_part += line + '\n'
  199. if current_part:
  200. parts.append(current_part)
  201. return parts
  202. def has_text(text):
  203. """Détermine si le texte fourni est non vide après suppression des espaces."""
  204. return bool(text.strip())
  205. def resize_image(image_bytes, mode='high', attachment_filename=None):
  206. """Redimensionne l'image selon le mode spécifié."""
  207. try:
  208. with Image.open(BytesIO(image_bytes)) as img:
  209. original_format = img.format # Stocker le format original
  210. if mode == 'high':
  211. img.thumbnail((2000, 2000))
  212. if min(img.size) < 768:
  213. scale = 768 / min(img.size)
  214. new_size = tuple(int(x * scale) for x in img.size)
  215. img = img.resize(new_size, Image.Resampling.LANCZOS)
  216. elif mode == 'low':
  217. img = img.resize((512, 512))
  218. buffer = BytesIO()
  219. img_format = img.format or _infer_image_format(attachment_filename)
  220. img.save(buffer, format=img_format)
  221. return buffer.getvalue()
  222. except Exception as e:
  223. logger.error(f"Erreur lors du redimensionnement de l'image : {e}")
  224. raise
  225. def _infer_image_format(filename):
  226. """Déduit le format de l'image basé sur l'extension du fichier."""
  227. if filename:
  228. _, ext = os.path.splitext(filename)
  229. ext = ext.lower()
  230. format_mapping = {
  231. '.jpg': 'JPEG',
  232. '.jpeg': 'JPEG',
  233. '.png': 'PNG',
  234. '.gif': 'GIF',
  235. '.bmp': 'BMP',
  236. '.tiff': 'TIFF'
  237. }
  238. return format_mapping.get(ext, 'PNG')
  239. return 'PNG'
  240. def extract_text_from_message(message):
  241. """Extrait le texte du message."""
  242. content = message.get("content", "")
  243. if isinstance(content, list):
  244. texts = [part.get("text", "") for part in content if isinstance(part, dict) and part.get("text")]
  245. return ' '.join(texts)
  246. elif isinstance(content, str):
  247. return content
  248. return ""
  249. def calculate_cost(usage, model='gpt-4o-mini'):
  250. """Calcule le coût basé sur l'utilisation des tokens."""
  251. input_tokens = usage.get('prompt_tokens', 0)
  252. output_tokens = usage.get('completion_tokens', 0)
  253. model_costs = {
  254. 'gpt-4o': {
  255. 'input_rate': 5.00 / 1_000_000, # 5$ pour 1M tokens d'entrée
  256. 'output_rate': 15.00 / 1_000_000 # 15$ pour 1M tokens de sortie
  257. },
  258. 'gpt-4o-mini': {
  259. 'input_rate': 0.150 / 1_000_000, # 0.150$ pour 1M tokens d'entrée
  260. 'output_rate': 0.600 / 1_000_000 # 0.600$ pour 1M tokens de sortie
  261. }
  262. }
  263. rates = model_costs.get(model, model_costs['gpt-4o-mini'])
  264. input_cost = input_tokens * rates['input_rate']
  265. output_cost = output_tokens * rates['output_rate']
  266. total_cost = input_cost + output_cost
  267. if model not in model_costs:
  268. logger.warning(f"Modèle inconnu '{model}'. Utilisation des tarifs par défaut pour 'gpt-4o-mini'.")
  269. return input_tokens, output_tokens, total_cost
  270. async def read_text_file(attachment):
  271. """Lit le contenu d'un fichier texte attaché."""
  272. file_bytes = await attachment.read()
  273. return file_bytes.decode('utf-8')
  274. async def encode_image_from_attachment(attachment, mode='high'):
  275. """Encode une image depuis une pièce jointe en base64 après redimensionnement."""
  276. image_data = await attachment.read()
  277. resized_image = resize_image(image_data, mode=mode, attachment_filename=attachment.filename)
  278. return base64.b64encode(resized_image).decode('utf-8')
  279. # =================================
  280. # Interaction avec OpenAI
  281. # =================================
  282. # Charger l'encodeur pour le modèle GPT-4o mini
  283. encoding = tiktoken.get_encoding("o200k_base")
  284. async def call_openai_model(model, messages, max_tokens, temperature=0.8):
  285. """Appelle un modèle OpenAI avec les paramètres spécifiés et gère la réponse."""
  286. try:
  287. response = await openai_client.chat.completions.create(
  288. model=model,
  289. messages=messages,
  290. max_tokens=max_tokens,
  291. temperature=temperature
  292. )
  293. if response and response.choices:
  294. reply = response.choices[0].message.content
  295. # Ne pas logger les réponses de 'gpt-4o-mini' et 'gpt-4o'
  296. if model not in ["gpt-4o-mini", "gpt-4o"]:
  297. logger.info(f"Réponse de {model}: {reply[:100]}...")
  298. if hasattr(response, 'usage') and response.usage:
  299. usage = {
  300. 'prompt_tokens': response.usage.prompt_tokens,
  301. 'completion_tokens': response.usage.completion_tokens
  302. }
  303. _, _, total_cost = calculate_cost(usage, model=model)
  304. # Log avec les tokens d'entrée et de sortie
  305. logger.info(f"Coût de l'utilisation de {model}: ${total_cost:.4f} / Input: {usage['prompt_tokens']} / Output: {usage['completion_tokens']}")
  306. else:
  307. logger.warning(f"Informations d'utilisation non disponibles pour {model}.")
  308. return reply
  309. except OpenAIError as e:
  310. logger.error(f"Erreur lors de l'appel à l'API OpenAI avec {model}: {e}")
  311. except Exception as e:
  312. logger.error(f"Erreur inattendue lors de l'appel à l'API OpenAI avec {model}: {e}")
  313. return None
  314. async def call_gpt4o_for_image_analysis(image_data, user_text=None, detail='high'):
  315. """Appelle GPT-4o pour analyser une image."""
  316. prompt = IMAGE_ANALYSIS_PROMPT
  317. if user_text:
  318. prompt += f" Voici ce que l'on te décrit : \"{user_text}\"."
  319. message_to_send = {
  320. "role": "user",
  321. "content": [
  322. {"type": "text", "text": prompt},
  323. {
  324. "type": "image_url",
  325. "image_url": {
  326. "url": f"data:image/jpeg;base64,{image_data}",
  327. "detail": detail
  328. }
  329. }
  330. ]
  331. }
  332. messages = [message_to_send]
  333. analysis = await call_openai_model(
  334. model="gpt-4o",
  335. messages=messages,
  336. max_tokens=4096,
  337. temperature=1.0
  338. )
  339. if analysis:
  340. logger.info(f"Analyse de l'image par GPT-4o : {analysis}")
  341. return analysis
  342. async def call_gpt4o_mini_with_analysis(analysis_text, user_name, user_question, has_text_flag):
  343. """Appelle GPT-4o Mini pour générer une réponse basée sur l'analyse de l'image."""
  344. system_messages = [
  345. {"role": "system", "content": PERSONALITY_PROMPT},
  346. {
  347. "role": "system",
  348. "content": f"L'analyse de l'image fournie est la suivante :\n{analysis_text}\n\n"
  349. }
  350. ]
  351. if has_text_flag:
  352. user_content = (
  353. f"{user_name} a posté un message contenant une image et a écrit avec : '{user_question}'. "
  354. "Réponds à l'utilisateur en te basant sur l'analyse, avec ta personnalité. "
  355. "Ne mentionne pas explicitement que l'analyse est pré-existante, fais comme si tu l'avais faite toi-même."
  356. )
  357. else:
  358. user_content = (
  359. f"{user_name} a partagé une image sans texte additionnel. "
  360. "Commente l'image en te basant sur l'analyse, avec ta personnalité. "
  361. "Ne mentionne pas que l'analyse a été fournie à l'avance, réagis comme si tu l'avais toi-même effectuée."
  362. )
  363. user_message = {"role": "user", "content": user_content}
  364. messages = system_messages + conversation_history + [user_message]
  365. reply = await call_openai_model(
  366. model="gpt-4o-mini",
  367. messages=messages,
  368. max_tokens=4096,
  369. temperature=1.0
  370. )
  371. return reply
  372. async def call_openai_api(user_text, user_name, image_data=None, detail='high'):
  373. """Appelle l'API OpenAI pour générer une réponse basée sur le texte et/ou l'image."""
  374. text = f"{user_name} dit : {user_text}"
  375. if image_data:
  376. text += " (a posté une image.)"
  377. message_to_send = {
  378. "role": "user",
  379. "content": [
  380. {"type": "text", "text": text}
  381. ]
  382. }
  383. if image_data:
  384. message_to_send["content"].append({
  385. "type": "image_url",
  386. "image_url": {
  387. "url": f"data:image/jpeg;base64,{image_data}",
  388. "detail": detail
  389. }
  390. })
  391. messages = [
  392. {"role": "system", "content": PERSONALITY_PROMPT}
  393. ] + conversation_history + [message_to_send]
  394. reply = await call_openai_model(
  395. model="gpt-4o-mini",
  396. messages=messages,
  397. max_tokens=4096,
  398. temperature=1.0
  399. )
  400. return reply
  401. # =====================================
  402. # Gestion du Contenu de l'Historique
  403. # =====================================
  404. async def remove_old_image_analyses(db_manager, new_analysis=False):
  405. """Supprime les anciennes analyses d'images de l'historique."""
  406. global conversation_history, last_analysis_index, messages_since_last_analysis
  407. if new_analysis:
  408. logger.debug("Nouvelle analyse détectée. Suppression des anciennes analyses.")
  409. conversation_history = [
  410. msg for msg in conversation_history
  411. if not (msg.get("role") == "system" and msg.get("content", "").startswith("__IMAGE_ANALYSIS__:"))
  412. ]
  413. last_analysis_index = len(conversation_history)
  414. messages_since_last_analysis = 0
  415. # Supprimer les analyses d'images de la base de données
  416. db_manager.delete_old_image_analyses()
  417. else:
  418. # Exemple de logique additionnelle si nécessaire
  419. pass
  420. async def add_to_conversation_history(db_manager, new_message):
  421. global conversation_history, last_analysis_index, messages_since_last_analysis
  422. # Exclure le PERSONALITY_PROMPT de l'historique
  423. if new_message.get("role") == "system" and new_message.get("content") == PERSONALITY_PROMPT:
  424. logger.debug("PERSONALITY_PROMPT système non ajouté à l'historique.")
  425. return
  426. # Gérer les analyses d'images
  427. if new_message.get("role") == "system" and new_message.get("content", "").startswith("__IMAGE_ANALYSIS__:"):
  428. await remove_old_image_analyses(db_manager, new_analysis=True)
  429. # Ajouter le message à l'historique en mémoire
  430. conversation_history.append(new_message)
  431. # Sauvegarder dans la base de données
  432. db_manager.save_message(new_message.get("role"), new_message.get("content"))
  433. logger.debug(f"Message ajouté à l'historique. Taille actuelle : {len(conversation_history)}")
  434. # Mettre à jour les indices pour les analyses d'images
  435. if new_message.get("role") == "system" and new_message.get("content", "").startswith("__IMAGE_ANALYSIS__:"):
  436. last_analysis_index = len(conversation_history) - 1
  437. messages_since_last_analysis = 0
  438. else:
  439. await remove_old_image_analyses(db_manager, new_analysis=False)
  440. # Limiter l'historique à 150 messages
  441. if len(conversation_history) > 150:
  442. excess = len(conversation_history) - 150
  443. conversation_history = conversation_history[excess:]
  444. # Supprimer les messages les plus anciens de la base de données
  445. db_manager.delete_old_messages(excess)
  446. # =====================================
  447. # Gestion des Événements Discord
  448. # =====================================
  449. class ReminderCommands(commands.Cog):
  450. """Cog pour les commandes de rappel."""
  451. def __init__(self, bot: commands.Bot, db_manager: DatabaseManager):
  452. self.bot = bot
  453. self.db_manager = db_manager
  454. @app_commands.command(name="rappel", description="Créer un rappel")
  455. @app_commands.describe(date="Date du rappel (DD/MM/YYYY)")
  456. @app_commands.describe(time="Heure du rappel (HH:MM, 24h)")
  457. @app_commands.describe(content="Contenu du rappel")
  458. async def rappel(self, interaction: discord.Interaction, date: str, time: str, content: str):
  459. """Commande pour créer un rappel."""
  460. user = interaction.user
  461. channel = interaction.channel
  462. # Valider et parser la date et l'heure
  463. try:
  464. remind_datetime_str = f"{date} {time}"
  465. remind_datetime = datetime.strptime(remind_datetime_str, "%d/%m/%Y %H:%M")
  466. # Vous pouvez ajuster le fuseau horaire selon vos besoins
  467. tz = pytz.timezone('Europe/Paris') # Exemple de fuseau horaire
  468. remind_datetime = tz.localize(remind_datetime)
  469. now = datetime.now(tz)
  470. if remind_datetime <= now:
  471. await interaction.response.send_message("❌ La date et l'heure doivent être dans le futur.", ephemeral=True)
  472. return
  473. except ValueError:
  474. await interaction.response.send_message("❌ Format de date ou d'heure invalide. Utilisez DD/MM/YYYY pour la date et HH:MM pour l'heure.", ephemeral=True)
  475. return
  476. # Ajouter le rappel à la base de données
  477. self.db_manager.add_reminder(
  478. user_id=str(user.id),
  479. channel_id=str(channel.id),
  480. remind_at=remind_datetime.strftime('%Y-%m-%d %H:%M:%S'),
  481. content=content
  482. )
  483. # Créer un embed pour la confirmation
  484. embed = discord.Embed(
  485. title="Rappel Créé ✅",
  486. description=(
  487. f"**Date et Heure** : {remind_datetime.strftime('%d/%m/%Y %H:%M')}\n"
  488. f"**Contenu** : {content}"
  489. ),
  490. color=0x00ff00, # Vert
  491. timestamp=datetime.now(timezone.utc)
  492. )
  493. embed.set_footer(text=f"Créé par {user}", icon_url=user.display_avatar.url if user.avatar else user.default_avatar.url)
  494. # Envoyer l'embed de confirmation
  495. await interaction.response.send_message(embed=embed)
  496. @rappel.error
  497. async def rappel_error(self, interaction: discord.Interaction, error):
  498. """Gère les erreurs de la commande rappel."""
  499. logger.error(f"Erreur lors de l'exécution de la commande rappel: {error}")
  500. await interaction.response.send_message("❌ Une erreur est survenue lors de la création du rappel.")
  501. class MyDiscordBot(commands.Bot):
  502. def __init__(self, db_manager, **kwargs):
  503. super().__init__(**kwargs)
  504. self.db_manager = db_manager
  505. self.message_queue = asyncio.Queue()
  506. self.reminder_task = None
  507. async def setup_hook(self):
  508. """Hook d'initialisation asynchrone pour configurer des tâches supplémentaires."""
  509. self.processing_task = asyncio.create_task(self.process_messages())
  510. self.reminder_task = asyncio.create_task(self.process_reminders())
  511. # Charger les commandes slash
  512. await self.add_cog(AdminCommands(self, self.db_manager))
  513. await self.add_cog(ReminderCommands(self, self.db_manager))
  514. await self.tree.sync() # Synchroniser les commandes slash
  515. async def close(self):
  516. if openai_client:
  517. await openai_client.close()
  518. self.db_manager.close_connection()
  519. self.processing_task.cancel()
  520. if self.reminder_task:
  521. self.reminder_task.cancel()
  522. await super().close()
  523. async def process_reminders(self):
  524. """Tâche en arrière-plan pour vérifier et envoyer les rappels."""
  525. await self.wait_until_ready()
  526. while not self.is_closed():
  527. try:
  528. now = datetime.now(pytz.timezone('Europe/Paris')) # Utiliser le même fuseau horaire
  529. reminders = self.db_manager.get_due_reminders(now.strftime('%Y-%m-%d %H:%M:%S'))
  530. for reminder in reminders:
  531. try:
  532. user = await self.fetch_user(int(reminder['user_id']))
  533. except discord.NotFound:
  534. logger.error(f"Utilisateur avec l'ID {reminder['user_id']} non trouvé.")
  535. continue
  536. except discord.HTTPException as e:
  537. logger.error(f"Erreur lors de la récupération de l'utilisateur {reminder['user_id']}: {e}")
  538. continue
  539. try:
  540. channel = await self.fetch_channel(int(reminder['channel_id']))
  541. except discord.NotFound:
  542. logger.error(f"Canal avec l'ID {reminder['channel_id']} non trouvé.")
  543. continue
  544. except discord.HTTPException as e:
  545. logger.error(f"Erreur lors de la récupération du canal {reminder['channel_id']}: {e}")
  546. continue
  547. if channel and user:
  548. personalized_content = await self.get_personalized_reminder(reminder['content'], user)
  549. try:
  550. reminder_message = f"{user.mention} 🕒 Rappel : {personalized_content}"
  551. await channel.send(reminder_message)
  552. logger.info(f"Rappel envoyé à {user} dans le canal {channel}.")
  553. self.db_manager.save_message('assistant', reminder_message)
  554. conversation_history.append({
  555. "role": "assistant",
  556. "content": reminder_message
  557. })
  558. except discord.Forbidden:
  559. logger.error(f"Permissions insuffisantes pour envoyer des messages dans le canal {channel}.")
  560. except discord.HTTPException as e:
  561. logger.error(f"Erreur lors de l'envoi du message dans le canal {channel}: {e}")
  562. else:
  563. logger.warning(f"Canal ou utilisateur introuvable pour le rappel ID {reminder['id']}.")
  564. # Supprimer le rappel après envoi
  565. self.db_manager.delete_reminder(reminder['id'])
  566. await asyncio.sleep(60) # Vérifier toutes les minutes
  567. except Exception as e:
  568. logger.error(f"Erreur dans la tâche de rappels: {e}")
  569. await asyncio.sleep(60)
  570. async def get_personalized_reminder(self, content, user):
  571. """Utilise l'API OpenAI pour personnaliser le contenu du rappel."""
  572. messages = [
  573. {"role": "system", "content": PERSONALITY_PROMPT},
  574. {"role": "user", "content": f"Personnalise le rappel suivant pour {user.name} : {content}"}
  575. ]
  576. reply = await call_openai_model(
  577. model="gpt-4o-mini",
  578. messages=messages,
  579. max_tokens=4096,
  580. temperature=1.0
  581. )
  582. return reply if reply else content
  583. async def on_ready(self):
  584. """Événement déclenché lorsque le bot est prêt."""
  585. logger.info(f'{BOT_NAME} connecté en tant que {self.user}')
  586. if not conversation_history:
  587. logger.info("Aucun historique trouvé. L'historique commence vide.")
  588. # Envoyer un message de version dans le canal Discord
  589. channel = self.get_channel(chatgpt_channel_id)
  590. if channel:
  591. try:
  592. embed = discord.Embed(
  593. title="Bot Démarré",
  594. description=f"🎉 {BOT_NAME} est en ligne ! Version {BOT_VERSION}",
  595. color=0x00ff00 # Vert
  596. )
  597. await channel.send(embed=embed)
  598. logger.info(f"Message de connexion envoyé dans le canal ID {chatgpt_channel_id}")
  599. except discord.Forbidden:
  600. logger.error(f"Permissions insuffisantes pour envoyer des messages dans le canal ID {chatgpt_channel_id}.")
  601. except discord.HTTPException as e:
  602. logger.error(f"Erreur lors de l'envoi du message de connexion : {e}")
  603. else:
  604. logger.error(f"Canal avec ID {chatgpt_channel_id} non trouvé.")
  605. async def on_message(self, message):
  606. """Événement déclenché lorsqu'un message est envoyé dans un canal suivi."""
  607. # Ignorer les messages provenant d'autres canaux ou du bot lui-même
  608. if message.channel.id != chatgpt_channel_id or message.author == self.user:
  609. return
  610. await self.message_queue.put(message)
  611. async def process_messages(self):
  612. """Tâche en arrière-plan pour traiter les messages séquentiellement."""
  613. while True:
  614. message = await self.message_queue.get()
  615. try:
  616. await self.handle_message(message)
  617. except Exception as e:
  618. logger.error(f"Erreur lors du traitement du message : {e}")
  619. try:
  620. await message.channel.send("Une erreur est survenue lors du traitement de votre message.")
  621. except Exception as send_error:
  622. logger.error(f"Erreur lors de l'envoi du message d'erreur : {send_error}")
  623. finally:
  624. self.message_queue.task_done()
  625. async def handle_message(self, message):
  626. """Fonction pour traiter un seul message."""
  627. global conversation_history, last_analysis_index, messages_since_last_analysis
  628. user_text = message.content.strip()
  629. # Traiter les pièces jointes
  630. image_data = None
  631. file_content = None
  632. attachment_filename = None
  633. allowed_extensions = ['.txt', '.py', '.html', '.css', '.js']
  634. if message.attachments:
  635. for attachment in message.attachments:
  636. if any(attachment.filename.lower().endswith(ext) for ext in allowed_extensions):
  637. file_content = await read_text_file(attachment)
  638. attachment_filename = attachment.filename
  639. break
  640. elif attachment.content_type and attachment.content_type.startswith('image/'):
  641. image_data = await encode_image_from_attachment(attachment, mode='high')
  642. break
  643. # Traitement des images
  644. if image_data:
  645. has_user_text = has_text(user_text)
  646. user_text_to_use = user_text if has_user_text else None
  647. temp_msg = await message.channel.send(f"*{BOT_NAME} observe l'image...*")
  648. try:
  649. # Analyser l'image avec GPT-4o
  650. analysis = await call_gpt4o_for_image_analysis(image_data, user_text=user_text_to_use)
  651. if analysis:
  652. # Ajouter l'analyse à l'historique
  653. analysis_message = {
  654. "role": "system",
  655. "content": f"__IMAGE_ANALYSIS__:{analysis}"
  656. }
  657. await add_to_conversation_history(self.db_manager, analysis_message)
  658. # Générer une réponse basée sur l'analyse
  659. reply = await call_gpt4o_mini_with_analysis(analysis, message.author.name, user_text, has_user_text)
  660. if reply:
  661. await temp_msg.delete()
  662. await message.channel.send(reply)
  663. # Construire et ajouter les messages à l'historique
  664. user_message_text = f"{user_text} (a posté une image.)" if has_user_text else (
  665. "Une image a été postée, mais elle n'est pas disponible pour analyse directe. Veuillez vous baser uniquement sur l'analyse fournie."
  666. )
  667. user_message = {
  668. "role": "user",
  669. "content": f"{message.author.name} dit : {user_message_text}"
  670. }
  671. assistant_message = {
  672. "role": "assistant",
  673. "content": reply
  674. }
  675. await add_to_conversation_history(self.db_manager, user_message)
  676. await add_to_conversation_history(self.db_manager, assistant_message)
  677. else:
  678. await temp_msg.delete()
  679. await message.channel.send("Désolé, je n'ai pas pu générer une réponse.")
  680. else:
  681. await temp_msg.delete()
  682. await message.channel.send("Désolé, je n'ai pas pu analyser l'image.")
  683. except Exception as e:
  684. await temp_msg.delete()
  685. await message.channel.send("Une erreur est survenue lors du traitement de l'image.")
  686. logger.error(f"Erreur lors du traitement de l'image: {e}")
  687. return # Ne pas continuer le traitement après une image
  688. # Ajouter le contenu du fichier au texte de l'utilisateur si un fichier est présent
  689. if file_content:
  690. user_text += f"\nContenu du fichier {attachment_filename}:\n{file_content}"
  691. # Vérifier si le texte n'est pas vide
  692. if not has_text(user_text):
  693. return # Ne pas appeler l'API si le texte est vide
  694. async with message.channel.typing():
  695. try:
  696. # Appeler l'API OpenAI pour le texte
  697. reply = await call_openai_api(user_text, message.author.name)
  698. if reply:
  699. # Diviser le message en plusieurs parties si nécessaire
  700. message_parts = split_message(reply)
  701. for part in message_parts:
  702. await message.channel.send(part)
  703. # Construire et ajouter les messages à l'historique
  704. user_message = {
  705. "role": "user",
  706. "content": f"{message.author.name} dit : {user_text}"
  707. }
  708. assistant_message = {
  709. "role": "assistant",
  710. "content": reply
  711. }
  712. await add_to_conversation_history(self.db_manager, user_message)
  713. await add_to_conversation_history(self.db_manager, assistant_message)
  714. else:
  715. await message.channel.send("Désolé, je n'ai pas pu générer une réponse.")
  716. except Exception as e:
  717. await message.channel.send("Une erreur est survenue lors de la génération de la réponse.")
  718. logger.error(f"Erreur lors du traitement du texte: {e}")
  719. # ============================
  720. # Commandes Slash via Cogs
  721. # ============================
  722. class AdminCommands(commands.Cog):
  723. """Cog pour les commandes administratives."""
  724. def __init__(self, bot: commands.Bot, db_manager):
  725. self.bot = bot
  726. self.db_manager = db_manager
  727. @app_commands.command(name="reset_history", description="Réinitialise l'historique des conversations.")
  728. @app_commands.checks.has_permissions(administrator=True)
  729. async def reset_history(self, interaction: discord.Interaction):
  730. """Réinitialise l'historique des conversations."""
  731. global conversation_history
  732. conversation_history = []
  733. self.db_manager.reset_history()
  734. await interaction.response.send_message("✅ L'historique des conversations a été réinitialisé.")
  735. @reset_history.error
  736. async def reset_history_error(self, interaction: discord.Interaction, error):
  737. """Gère les erreurs de la commande reset_history."""
  738. if isinstance(error, app_commands.CheckFailure):
  739. await interaction.response.send_message("❌ Vous n'avez pas la permission d'utiliser cette commande.")
  740. else:
  741. logger.error(f"Erreur lors de l'exécution de la commande reset_history: {error}")
  742. await interaction.response.send_message("Une erreur est survenue lors de l'exécution de la commande.")
  743. # ============================
  744. # Démarrage du Bot Discord
  745. # ============================
  746. def main():
  747. db_manager = DatabaseManager()
  748. if not db_manager.connection:
  749. logger.error("Le bot ne peut pas démarrer sans connexion à la base de données.")
  750. return
  751. db_manager.load_conversation_history()
  752. # Initialiser le bot avec le préfixe "!" et les intents définis
  753. bot = MyDiscordBot(command_prefix="!", db_manager=db_manager, intents=intents)
  754. # Démarrer le bot
  755. try:
  756. bot.run(DISCORD_TOKEN)
  757. except Exception as e:
  758. logger.error(f"Erreur lors du démarrage du bot Discord: {e}")
  759. finally:
  760. db_manager.close_connection()
  761. if __name__ == "__main__":
  762. main()