1
0

chatbot.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import discord
  2. from discord.ext import commands
  3. import requests
  4. import json
  5. import os
  6. import random
  7. import re
  8. from dotenv import load_dotenv
  9. import logging
  10. from logging.handlers import RotatingFileHandler
  11. # Configuration du logger
  12. logger = logging.getLogger('discord_bot')
  13. logger.setLevel(logging.INFO)
  14. formatter = logging.Formatter('[%(asctime)s] %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
  15. # Créer un gestionnaire de fichier avec rotation
  16. file_handler = RotatingFileHandler('bot.log', maxBytes=5*1024*1024, backupCount=2) # 5 Mo par fichier, garder 2 sauvegardes
  17. file_handler.setFormatter(formatter)
  18. logger.addHandler(file_handler)
  19. # Optionnel : ajouter un gestionnaire de console pour afficher les logs dans la console
  20. console_handler = logging.StreamHandler()
  21. console_handler.setFormatter(formatter)
  22. logger.addHandler(console_handler)
  23. # Charger les variables d'environnement
  24. load_dotenv()
  25. # Version du bot
  26. VERSION = "4.5.0" # Modifiable selon la version actuelle
  27. # Récupérer les variables d'environnement avec validation
  28. def get_env_variable(var_name, is_critical=True, default=None, var_type=str):
  29. value = os.getenv(var_name)
  30. if value is None:
  31. if is_critical:
  32. logger.error(f"Variable d'environnement critique manquante: {var_name}")
  33. if default is not None:
  34. logger.warning(f"Utilisation de la valeur par défaut pour {var_name}")
  35. return default
  36. else:
  37. raise ValueError(f"La variable d'environnement {var_name} est requise mais non définie.")
  38. else:
  39. logger.warning(f"Variable d'environnement non critique manquante: {var_name}. Utilisation de la valeur par défaut: {default}")
  40. return default
  41. if var_type == int:
  42. try:
  43. return int(value)
  44. except ValueError:
  45. logger.error(f"La variable d'environnement {var_name} doit être un entier. Valeur actuelle: {value}")
  46. if default is not None:
  47. return default
  48. else:
  49. raise ValueError(f"La variable d'environnement {var_name} doit être un entier.")
  50. return value
  51. try:
  52. # Variables d'environnement critiques
  53. MISTRAL_API_KEY = get_env_variable('MISTRAL_API_KEY')
  54. DISCORD_TOKEN = get_env_variable('DISCORD_TOKEN')
  55. CHANNEL_ID = get_env_variable('CHANNEL_ID', var_type=int)
  56. # Variables d'environnement non critiques avec valeurs par défaut
  57. MAX_HISTORY_LENGTH = get_env_variable('MAX_HISTORY_LENGTH', is_critical=False, default=10, var_type=int)
  58. HISTORY_FILE = get_env_variable('HISTORY_FILE', is_critical=False, default="conversation_history.json")
  59. logger.info("Toutes les variables d'environnement critiques ont été chargées avec succès.")
  60. except ValueError as e:
  61. logger.error(f"Erreur lors du chargement des variables d'environnement: {e}")
  62. # Si une variable critique est manquante, le bot ne peut pas fonctionner correctement.
  63. # Il est donc préférable de quitter le programme avec un code d'erreur.
  64. exit(1)
  65. # Endpoint API Mistral
  66. MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions"
  67. def load_history():
  68. """Charge l'historique depuis un fichier JSON."""
  69. if os.path.exists(HISTORY_FILE):
  70. with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
  71. try:
  72. data = json.load(f)
  73. # Vérifier et limiter la taille de chaque historique
  74. for channel_id in data:
  75. if "messages" in data[channel_id]:
  76. if len(data[channel_id]["messages"]) > MAX_HISTORY_LENGTH:
  77. data[channel_id]["messages"] = data[channel_id]["messages"][-MAX_HISTORY_LENGTH:]
  78. return data
  79. except json.JSONDecodeError:
  80. logger.error("Erreur de lecture du fichier d'historique. Création d'un nouveau fichier.")
  81. return {}
  82. return {}
  83. def save_history(history):
  84. """Sauvegarde l'historique dans un fichier JSON."""
  85. with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
  86. json.dump(history, f, ensure_ascii=False, indent=4)
  87. def get_personality_prompt():
  88. try:
  89. with open('personality_prompt.txt', 'r', encoding='utf-8') as file:
  90. return file.read().strip()
  91. except FileNotFoundError:
  92. logger.error("Le fichier personality_prompt.txt n'a pas été trouvé. Utilisation d'un prompt par défaut.")
  93. return """Tu es un assistant utile et poli qui peut analyser des images.
  94. Quand on te montre une image, décris-la et donne ton avis si on te le demande.
  95. Réponds toujours en français avec un ton naturel et amical.
  96. Lorsque tu analyses une image, décris d'abord ce que tu vois en détail,
  97. puis réponds à la question si elle est posée. Utilise un langage clair et accessible."""
  98. # Charger l'historique au démarrage
  99. conversation_history = load_history()
  100. intents = discord.Intents.default()
  101. intents.messages = True
  102. intents.message_content = True
  103. intents.presences = True
  104. bot = commands.Bot(command_prefix='!', intents=intents)
  105. @bot.event
  106. async def on_ready():
  107. logger.info(f'Le bot est connecté en tant que {bot.user}')
  108. global conversation_history
  109. conversation_history = load_history()
  110. channel = bot.get_channel(CHANNEL_ID)
  111. if channel is not None:
  112. guild = channel.guild
  113. if guild is not None:
  114. bot_member = guild.me
  115. bot_nickname = bot_member.display_name
  116. else:
  117. bot_nickname = bot.user.name
  118. embed = discord.Embed(
  119. title="Bot en ligne",
  120. description=f"{bot_nickname} est désormais en ligne. Version {VERSION}.",
  121. color=discord.Color.green()
  122. )
  123. await channel.send(embed=embed)
  124. await bot.tree.sync() # Synchroniser les commandes slash
  125. def call_mistral_api(prompt, history, image_url=None, user_id=None, username=None):
  126. headers = {
  127. "Content-Type": "application/json",
  128. "Authorization": f"Bearer {MISTRAL_API_KEY}"
  129. }
  130. personality_prompt = get_personality_prompt()
  131. # Vérifier si la structure messages existe
  132. if "messages" not in history:
  133. history["messages"] = []
  134. # Création du message utilisateur selon qu'il y a une image ou non
  135. if image_url:
  136. # Format multimodal pour les messages avec image
  137. user_content = [
  138. {"type": "text", "text": f"{username}: {prompt}" if username else prompt},
  139. {
  140. "type": "image_url",
  141. "image_url": {
  142. "url": image_url,
  143. "detail": "high" # Demander une analyse détaillée de l'image
  144. }
  145. }
  146. ]
  147. user_message = {
  148. "role": "user",
  149. "content": user_content
  150. }
  151. else:
  152. # Format standard pour les messages texte seulement
  153. user_content = f"{username}: {prompt}" if username else prompt
  154. user_message = {"role": "user", "content": user_content}
  155. # Ajouter le message utilisateur à l'historique
  156. history["messages"].append(user_message)
  157. # Limiter l'historique à MAX_HISTORY_LENGTH messages
  158. if len(history["messages"]) > MAX_HISTORY_LENGTH:
  159. history["messages"] = history["messages"][-MAX_HISTORY_LENGTH:]
  160. # Préparer les messages pour l'API
  161. messages = []
  162. # Ajouter le message système en premier
  163. messages.append({"role": "system", "content": personality_prompt})
  164. # Ajouter l'historique des messages (en gardant le format)
  165. for msg in history["messages"]:
  166. if isinstance(msg["content"], list): # C'est un message multimodal
  167. messages.append({
  168. "role": msg["role"],
  169. "content": msg["content"]
  170. })
  171. else: # C'est un message texte standard
  172. messages.append({
  173. "role": msg["role"],
  174. "content": msg["content"]
  175. })
  176. data = {
  177. "model": "mistral-medium-2508",
  178. "messages": messages,
  179. "max_tokens": 1000
  180. }
  181. try:
  182. response = requests.post(MISTRAL_API_URL, headers=headers, data=json.dumps(data))
  183. response.raise_for_status() # Lève une exception pour les erreurs HTTP
  184. if response.status_code == 200:
  185. response_data = response.json()
  186. # Vérifier si la réponse contient bien le champ attendu
  187. if 'choices' in response_data and len(response_data['choices']) > 0:
  188. assistant_response = response_data['choices'][0]['message']['content']
  189. # Ajouter la réponse de l'assistant à l'historique
  190. history["messages"].append({"role": "assistant", "content": assistant_response})
  191. # Limiter à nouveau après avoir ajouté la réponse
  192. if len(history["messages"]) > MAX_HISTORY_LENGTH:
  193. history["messages"] = history["messages"][-MAX_HISTORY_LENGTH:]
  194. # Sauvegarder l'historique après chaque modification
  195. save_history(conversation_history)
  196. return assistant_response
  197. else:
  198. logger.error(f"Réponse API inattendue: {response_data}")
  199. return "Désolé, je n'ai pas reçu de réponse valide de l'API."
  200. else:
  201. return f"Erreur API: {response.status_code}"
  202. except requests.exceptions.RequestException as e:
  203. logger.error(f"Erreur lors de l'appel API: {e}")
  204. return "Désolé, une erreur réseau est survenue lors de la communication avec l'API."
  205. @bot.tree.command(name="reset", description="Réinitialise l'historique de conversation")
  206. async def reset_history_slash(interaction: discord.Interaction):
  207. channel_id = str(interaction.channel.id)
  208. if channel_id in conversation_history:
  209. # Conserver le même ID de conversation mais vider les messages
  210. if "messages" in conversation_history[channel_id]:
  211. conversation_history[channel_id]["messages"] = []
  212. else:
  213. conversation_history[channel_id] = {
  214. "conversation_id": conversation_history[channel_id].get("conversation_id", str(len(conversation_history) + 1)),
  215. "messages": []
  216. }
  217. save_history(conversation_history)
  218. await interaction.response.send_message("L'historique de conversation a été réinitialisé.")
  219. else:
  220. conversation_id = str(len(conversation_history) + 1)
  221. conversation_history[channel_id] = {
  222. "conversation_id": conversation_id,
  223. "messages": []
  224. }
  225. save_history(conversation_history)
  226. await interaction.response.send_message("Aucun historique de conversation trouvé pour ce channel. Créé un nouvel historique.")
  227. @bot.event
  228. async def on_message(message):
  229. # Ignorer les messages du bot lui-même
  230. if message.author == bot.user:
  231. return
  232. # Vérifier si le bot est mentionné dans le message
  233. if bot.user.mentioned_in(message):
  234. # Vérifier si le message provient du channel spécifique
  235. if message.channel.id == CHANNEL_ID:
  236. # Traiter comme avant (ignorer pour l'instant, car nous voulons que la nouvelle fonctionnalité s'applique partout sauf dans CHANNEL_ID)
  237. pass
  238. else:
  239. # Récupérer les vingt derniers messages dans ce canal (sans compter le message actuel)
  240. context_messages = []
  241. async for msg in message.channel.history(limit=20, before=message):
  242. # Remplacer les mentions par les noms d'utilisateur pour éviter les références circulaires
  243. resolved_content = msg.content
  244. for user in msg.mentions:
  245. resolved_content = resolved_content.replace(f"<@{user.id}>", f"@{user.display_name}")
  246. # Ajouter le nom de l'auteur avant le contenu du message
  247. author_name = msg.author.display_name
  248. context_messages.append(f"{author_name}: {resolved_content}")
  249. # Inverser l'ordre pour avoir les messages du plus ancien au plus récent
  250. context_messages.reverse()
  251. # Construire le contexte
  252. context = "\n".join(context_messages)
  253. # Préparer le prompt avec le contexte
  254. # Remplacer les mentions dans le message actuel
  255. resolved_content = message.content
  256. for user in message.mentions:
  257. resolved_content = resolved_content.replace(f"<@{user.id}>", f"@{user.display_name}")
  258. # Supprimer la mention du bot du message pour éviter les répétitions
  259. bot_mention = f"<@{bot.user.id}>"
  260. if bot_mention in resolved_content:
  261. resolved_content = resolved_content.replace(bot_mention, "").strip()
  262. prompt = f"Contexte de la conversation récente:\n{context}\n\nNouveau message: {resolved_content}"
  263. # Utiliser le prompt pour appeler l'API Mistral
  264. channel_id = str(message.channel.id)
  265. # Créer un historique temporaire pour cette conversation
  266. temp_history = {
  267. "messages": [
  268. {"role": "system", "content": get_personality_prompt()},
  269. {"role": "user", "content": prompt}
  270. ]
  271. }
  272. # Appeler l'API Mistral
  273. async with message.channel.typing():
  274. try:
  275. response = call_mistral_api(
  276. prompt,
  277. temp_history, # Utiliser l'historique temporaire
  278. None, # Pas d'image ici
  279. user_id=str(message.author.id),
  280. username=message.author.display_name
  281. )
  282. await message.channel.send(response)
  283. except Exception as e:
  284. logger.error(f"Erreur lors de l'appel à l'API: {e}")
  285. await message.channel.send("Désolé, une erreur est survenue lors du traitement de votre demande.")
  286. return
  287. # Vérifier si le message provient du channel spécifique
  288. if message.channel.id != CHANNEL_ID:
  289. return
  290. # Le reste de la fonction on_message pour le traitement normal dans le canal spécifique
  291. # Gestion des stickers (code existant)
  292. if message.stickers:
  293. guild = message.guild
  294. if guild:
  295. stickers = guild.stickers
  296. if stickers:
  297. random_stickers = random.sample(stickers, len(stickers))
  298. for sticker in random_stickers:
  299. try:
  300. logger.info(f"Envoi du sticker: {sticker.name} (ID: {sticker.id})")
  301. await message.channel.send(stickers=[sticker])
  302. break
  303. except discord.errors.Forbidden as e:
  304. logger.error(f"Erreur lors de l'envoi du sticker: {sticker.name} (ID: {sticker.id}). Erreur: {e}")
  305. continue
  306. else:
  307. logger.error("Aucun sticker utilisable trouvé sur ce serveur.")
  308. await message.channel.send("Aucun sticker utilisable trouvé sur ce serveur.")
  309. else:
  310. await message.channel.send("Aucun sticker personnalisé trouvé sur ce serveur.")
  311. else:
  312. await message.channel.send("Ce message ne provient pas d'un serveur.")
  313. return
  314. # Gestion des emojis personnalisés (code existant)
  315. emoji_pattern = re.compile(r'^<a?:\w+:\d+>$')
  316. content = message.content.strip()
  317. if emoji_pattern.match(content):
  318. guild = message.guild
  319. if guild and guild.emojis:
  320. random_emoji = random.choice(guild.emojis)
  321. try:
  322. await message.channel.send(str(random_emoji))
  323. return
  324. except discord.errors.Forbidden as e:
  325. logger.error(f"Erreur lors de l'envoi de l'emoji: {random_emoji.name} (ID: {random_emoji.id}). Erreur: {e}")
  326. await message.channel.send("Je n'ai pas pu envoyer d'emoji en réponse.")
  327. else:
  328. await message.channel.send("Aucun emoji personnalisé trouvé sur ce serveur.")
  329. return
  330. # Traitement des images et autres fonctionnalités (code existant)
  331. if message.attachments:
  332. image_count = 0
  333. non_image_files = []
  334. too_large_images = []
  335. max_size = 5 * 1024 * 1024 # 5 Mo en octets
  336. for attachment in message.attachments:
  337. is_image = False
  338. if attachment.content_type and attachment.content_type.startswith('image/'):
  339. is_image = True
  340. else:
  341. image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.svg']
  342. if any(attachment.filename.lower().endswith(ext) for ext in image_extensions):
  343. is_image = True
  344. if is_image:
  345. image_count += 1
  346. if attachment.size > max_size:
  347. too_large_images.append(attachment.filename)
  348. else:
  349. non_image_files.append(attachment.filename)
  350. if non_image_files:
  351. file_list = ", ".join(non_image_files)
  352. await message.channel.send(f"Erreur : Les fichiers suivants ne sont pas des images et ne sont pas pris en charge : {file_list}. Veuillez envoyer uniquement des images.")
  353. return
  354. if image_count > 1:
  355. await message.channel.send("Erreur : Vous ne pouvez pas envoyer plus d'une image en un seul message.")
  356. return
  357. if too_large_images:
  358. image_list = ", ".join(too_large_images)
  359. await message.channel.send(f"Erreur : Les images suivantes dépassent la limite de 5 Mo : {image_list}. Veuillez envoyer des images plus petites.")
  360. return
  361. # Récupérer ou initialiser l'historique pour ce channel
  362. channel_id = str(message.channel.id)
  363. global conversation_history
  364. conversation_history = load_history()
  365. if channel_id not in conversation_history:
  366. conversation_id = str(len(conversation_history) + 1)
  367. conversation_history[channel_id] = {
  368. "conversation_id": conversation_id,
  369. "messages": []
  370. }
  371. if "messages" not in conversation_history[channel_id]:
  372. conversation_history[channel_id]["messages"] = []
  373. # Traitement des images dans le message (code existant)
  374. image_url = None
  375. if message.attachments:
  376. for attachment in message.attachments:
  377. if attachment.content_type and attachment.content_type.startswith('image/'):
  378. image_url = attachment.url
  379. break
  380. # Utiliser le contenu résolu (avec les mentions remplacées)
  381. resolved_content = message.content
  382. for user in message.mentions:
  383. resolved_content = resolved_content.replace(f"<@{user.id}>", f"@{user.display_name}")
  384. prompt = resolved_content
  385. # Appeler l'API Mistral (code existant)
  386. async with message.channel.typing():
  387. try:
  388. response = call_mistral_api(
  389. prompt,
  390. conversation_history[channel_id],
  391. image_url,
  392. user_id=str(message.author.id),
  393. username=message.author.display_name
  394. )
  395. await message.channel.send(response)
  396. except Exception as e:
  397. logger.error(f"Erreur lors de l'appel à l'API: {e}")
  398. await message.channel.send("Désolé, une erreur est survenue lors du traitement de votre demande.")
  399. # Assurer que les autres gestionnaires d'événements reçoivent également le message
  400. await bot.process_commands(message)
  401. bot.run(DISCORD_TOKEN)