chatbot.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  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. from datetime import datetime
  10. import logging
  11. from logging.handlers import RotatingFileHandler
  12. # Configuration du logger
  13. logger = logging.getLogger('discord_bot')
  14. logger.setLevel(logging.INFO)
  15. formatter = logging.Formatter('[%(asctime)s] %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
  16. file_handler = RotatingFileHandler('bot.log', maxBytes=5*1024*1024, backupCount=2)
  17. file_handler.setFormatter(formatter)
  18. logger.addHandler(file_handler)
  19. console_handler = logging.StreamHandler()
  20. console_handler.setFormatter(formatter)
  21. logger.addHandler(console_handler)
  22. # Charger les variables d'environnement
  23. load_dotenv()
  24. # Version du bot
  25. VERSION = "4.6.0"
  26. def get_env_variable(var_name, is_critical=True, default=None, var_type=str):
  27. value = os.getenv(var_name)
  28. if value is None:
  29. if is_critical:
  30. logger.error(f"Variable d'environnement critique manquante: {var_name}")
  31. if default is not None:
  32. logger.warning(f"Utilisation de la valeur par défaut pour {var_name}")
  33. return default
  34. else:
  35. raise ValueError(f"La variable d'environnement {var_name} est requise mais non définie.")
  36. else:
  37. logger.warning(f"Variable d'environnement non critique manquante: {var_name}. Utilisation de la valeur par défaut: {default}")
  38. return default
  39. if var_type == int:
  40. try:
  41. return int(value)
  42. except ValueError:
  43. logger.error(f"La variable d'environnement {var_name} doit être un entier. Valeur actuelle: {value}")
  44. if default is not None:
  45. return default
  46. else:
  47. raise ValueError(f"La variable d'environnement {var_name} doit être un entier.")
  48. return value
  49. try:
  50. MISTRAL_API_KEY = get_env_variable('MISTRAL_API_KEY')
  51. DISCORD_TOKEN = get_env_variable('DISCORD_TOKEN')
  52. CHANNEL_ID = get_env_variable('CHANNEL_ID', var_type=int)
  53. MAX_HISTORY_LENGTH = get_env_variable('MAX_HISTORY_LENGTH', is_critical=False, default=10, var_type=int)
  54. HISTORY_FILE = get_env_variable('HISTORY_FILE', is_critical=False, default="conversation_history.json")
  55. logger.info("Toutes les variables d'environnement critiques ont été chargées avec succès.")
  56. except ValueError as e:
  57. logger.error(f"Erreur lors du chargement des variables d'environnement: {e}")
  58. exit(1)
  59. MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions"
  60. class ConversationHistory:
  61. def __init__(self, file_path, max_length):
  62. self.file_path = file_path
  63. self.max_length = max_length
  64. self.history = self.load_history()
  65. def load_history(self):
  66. if os.path.exists(self.file_path):
  67. try:
  68. with open(self.file_path, 'r', encoding='utf-8') as f:
  69. data = json.load(f)
  70. for channel_id in data:
  71. if "messages" in data[channel_id]:
  72. if len(data[channel_id]["messages"]) > self.max_length:
  73. data[channel_id]["messages"] = data[channel_id]["messages"][-self.max_length:]
  74. return data
  75. except json.JSONDecodeError:
  76. logger.error("Erreur de lecture du fichier d'historique. Création d'un nouveau fichier.")
  77. return {}
  78. return {}
  79. def save_history(self):
  80. with open(self.file_path, 'w', encoding='utf-8') as f:
  81. json.dump(self.history, f, ensure_ascii=False, indent=4)
  82. def add_message(self, channel_id, message):
  83. if channel_id not in self.history:
  84. self.history[channel_id] = {"messages": []}
  85. self.history[channel_id]["messages"].append(message)
  86. if len(self.history[channel_id]["messages"]) > self.max_length:
  87. self.history[channel_id]["messages"] = self.history[channel_id]["messages"][-self.max_length:]
  88. self.save_history()
  89. def get_history(self, channel_id):
  90. if channel_id in self.history:
  91. return self.history[channel_id]
  92. else:
  93. self.history[channel_id] = {"messages": []}
  94. return self.history[channel_id]
  95. def reset_history(self, channel_id):
  96. if channel_id in self.history:
  97. self.history[channel_id]["messages"] = []
  98. else:
  99. self.history[channel_id] = {"messages": []}
  100. self.save_history()
  101. def get_personality_prompt():
  102. try:
  103. with open('personality_prompt.txt', 'r', encoding='utf-8') as file:
  104. return file.read().strip()
  105. except FileNotFoundError:
  106. logger.error("Le fichier personality_prompt.txt n'a pas été trouvé. Utilisation d'un prompt par défaut.")
  107. return """Tu es un assistant utile et poli qui peut analyser des images.
  108. Quand on te montre une image, décris-la et donne ton avis si on te le demande.
  109. Réponds toujours en français avec un ton naturel et amical.
  110. Lorsque tu analyses une image, décris d'abord ce que tu vois en détail,
  111. puis réponds à la question si elle est posée. Utilise un langage clair et accessible."""
  112. history_manager = ConversationHistory(HISTORY_FILE, MAX_HISTORY_LENGTH)
  113. def call_mistral_api(prompt, history, image_url=None, user_id=None, username=None):
  114. headers = {
  115. "Content-Type": "application/json",
  116. "Authorization": f"Bearer {MISTRAL_API_KEY}"
  117. }
  118. personality_prompt = get_personality_prompt()
  119. current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  120. if image_url:
  121. user_content = [
  122. {"type": "text", "text": f"{username}: {prompt} (Date et heure : {current_time})" if username else f"{prompt} (Date et heure : {current_time})"},
  123. {
  124. "type": "image_url",
  125. "image_url": {
  126. "url": image_url,
  127. "detail": "high"
  128. }
  129. }
  130. ]
  131. user_message = {"role": "user", "content": user_content}
  132. else:
  133. user_content = f"{username}: {prompt} (Date et heure : {current_time})" if username else f"{prompt} (Date et heure : {current_time})"
  134. user_message = {"role": "user", "content": user_content}
  135. history["messages"].append(user_message)
  136. if len(history["messages"]) > MAX_HISTORY_LENGTH:
  137. history["messages"] = history["messages"][-MAX_HISTORY_LENGTH:]
  138. messages = [{"role": "system", "content": personality_prompt}]
  139. for msg in history["messages"]:
  140. messages.append({
  141. "role": msg["role"],
  142. "content": msg["content"] if isinstance(msg["content"], list) else msg["content"]
  143. })
  144. data = {
  145. "model": "mistral-medium-latest",
  146. "messages": messages,
  147. "max_tokens": 1000
  148. }
  149. try:
  150. response = requests.post(MISTRAL_API_URL, headers=headers, data=json.dumps(data))
  151. response.raise_for_status()
  152. if response.status_code == 200:
  153. response_data = response.json()
  154. if 'choices' in response_data and len(response_data['choices']) > 0:
  155. assistant_response = response_data['choices'][0]['message']['content']
  156. history["messages"].append({"role": "assistant", "content": assistant_response})
  157. if 'usage' in response_data:
  158. prompt_tokens = response_data['usage']['prompt_tokens']
  159. completion_tokens = response_data['usage']['completion_tokens']
  160. input_cost = (prompt_tokens / 1_000_000) * 0.4
  161. output_cost = (completion_tokens / 1_000_000) * 2
  162. total_cost = input_cost + output_cost
  163. logger.info(f"Mistral API Call - Input Tokens: {prompt_tokens}, Output Tokens: {completion_tokens}, Cost: ${total_cost:.6f}")
  164. else:
  165. logger.warning("La réponse de l'API ne contient pas d'informations sur les tokens.")
  166. if len(history["messages"]) > MAX_HISTORY_LENGTH:
  167. history["messages"] = history["messages"][-MAX_HISTORY_LENGTH:]
  168. history_manager.save_history()
  169. return assistant_response
  170. else:
  171. logger.error(f"Réponse API inattendue: {response_data}")
  172. return "Désolé, je n'ai pas reçu de réponse valide de l'API."
  173. else:
  174. return f"Erreur API: {response.status_code}"
  175. except requests.exceptions.RequestException as e:
  176. logger.error(f"Erreur lors de l'appel API: {e}")
  177. return "Désolé, une erreur réseau est survenue lors de la communication avec l'API."
  178. intents = discord.Intents.default()
  179. intents.messages = True
  180. intents.message_content = True
  181. intents.presences = True
  182. bot = commands.Bot(command_prefix='!', intents=intents)
  183. @bot.event
  184. async def on_ready():
  185. logger.info(f'Le bot est connecté en tant que {bot.user}')
  186. history_manager.history = history_manager.load_history()
  187. channel = bot.get_channel(CHANNEL_ID)
  188. if channel is not None:
  189. guild = channel.guild
  190. if guild is not None:
  191. bot_member = guild.me
  192. bot_nickname = bot_member.display_name
  193. else:
  194. bot_nickname = bot.user.name
  195. embed = discord.Embed(
  196. title="Bot en ligne",
  197. description=f"{bot_nickname} est désormais en ligne. Version {VERSION}.",
  198. color=discord.Color.green()
  199. )
  200. await channel.send(embed=embed)
  201. await bot.tree.sync()
  202. @bot.tree.command(name="reset", description="Réinitialise l'historique de conversation")
  203. async def reset_history_slash(interaction: discord.Interaction):
  204. channel_id = str(interaction.channel.id)
  205. history_manager.reset_history(channel_id)
  206. await interaction.response.send_message("L'historique de conversation a été réinitialisé.")
  207. async def handle_stickers(message):
  208. if message.stickers:
  209. guild = message.guild
  210. if guild:
  211. stickers = guild.stickers
  212. if stickers:
  213. random_stickers = random.sample(stickers, len(stickers))
  214. for sticker in random_stickers:
  215. try:
  216. logger.info(f"Envoi du sticker: {sticker.name} (ID: {sticker.id})")
  217. await message.channel.send(stickers=[sticker])
  218. break
  219. except discord.errors.Forbidden as e:
  220. logger.error(f"Erreur lors de l'envoi du sticker: {sticker.name} (ID: {sticker.id}). Erreur: {e}")
  221. continue
  222. else:
  223. logger.error("Aucun sticker utilisable trouvé sur ce serveur.")
  224. await message.channel.send("Aucun sticker utilisable trouvé sur ce serveur.")
  225. else:
  226. await message.channel.send("Aucun sticker personnalisé trouvé sur ce serveur.")
  227. else:
  228. await message.channel.send("Ce message ne provient pas d'un serveur.")
  229. return True
  230. return False
  231. async def handle_emojis(message):
  232. emoji_pattern = re.compile(r'^<a?:\w+:\d+>$')
  233. content = message.content.strip()
  234. if emoji_pattern.match(content):
  235. guild = message.guild
  236. if guild and guild.emojis:
  237. random_emoji = random.choice(guild.emojis)
  238. try:
  239. await message.channel.send(str(random_emoji))
  240. return True
  241. except discord.errors.Forbidden as e:
  242. logger.error(f"Erreur lors de l'envoi de l'emoji: {random_emoji.name} (ID: {random_emoji.id}). Erreur: {e}")
  243. await message.channel.send("Je n'ai pas pu envoyer d'emoji en réponse.")
  244. else:
  245. await message.channel.send("Aucun emoji personnalisé trouvé sur ce serveur.")
  246. return True
  247. return False
  248. async def handle_images(message):
  249. if message.attachments:
  250. image_count = 0
  251. non_image_files = []
  252. too_large_images = []
  253. max_size = 5 * 1024 * 1024 # 5 Mo en octets
  254. for attachment in message.attachments:
  255. is_image = False
  256. if attachment.content_type and attachment.content_type.startswith('image/'):
  257. is_image = True
  258. else:
  259. image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.svg']
  260. if any(attachment.filename.lower().endswith(ext) for ext in image_extensions):
  261. is_image = True
  262. if is_image:
  263. image_count += 1
  264. if attachment.size > max_size:
  265. too_large_images.append(attachment.filename)
  266. else:
  267. non_image_files.append(attachment.filename)
  268. if non_image_files:
  269. file_list = ", ".join(non_image_files)
  270. 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.")
  271. return False
  272. if image_count > 1:
  273. await message.channel.send("Erreur : Vous ne pouvez pas envoyer plus d'une image en un seul message.")
  274. return False
  275. if too_large_images:
  276. image_list = ", ".join(too_large_images)
  277. await message.channel.send(f"Erreur : Les images suivantes dépassent la limite de 5 Mo : {image_list}. Veuillez envoyer des images plus petites.")
  278. return False
  279. return True
  280. async def handle_bot_mention(message):
  281. context_messages = []
  282. async for msg in message.channel.history(limit=20, before=message):
  283. resolved_content = msg.content
  284. for user in msg.mentions:
  285. resolved_content = resolved_content.replace(f"<@{user.id}>", f"@{user.display_name}")
  286. author_name = msg.author.display_name
  287. context_messages.append(f"{author_name}: {resolved_content}")
  288. context_messages.reverse()
  289. context = "\n".join(context_messages)
  290. resolved_content = message.content
  291. for user in message.mentions:
  292. resolved_content = resolved_content.replace(f"<@{user.id}>", f"@{user.display_name}")
  293. bot_mention = f"<@{bot.user.id}>"
  294. if bot_mention in resolved_content:
  295. resolved_content = resolved_content.replace(bot_mention, "").strip()
  296. prompt = f"Contexte de la conversation récente:\n{context}\n\nNouveau message: {resolved_content}"
  297. channel_id = str(message.channel.id)
  298. temp_history = {
  299. "messages": [
  300. {"role": "system", "content": get_personality_prompt()},
  301. {"role": "user", "content": prompt}
  302. ]
  303. }
  304. async with message.channel.typing():
  305. try:
  306. response = call_mistral_api(
  307. prompt,
  308. temp_history,
  309. None,
  310. user_id=str(message.author.id),
  311. username=message.author.display_name
  312. )
  313. await message.channel.send(response)
  314. except Exception as e:
  315. logger.error(f"Erreur lors de l'appel à l'API: {e}")
  316. await message.channel.send("Désolé, une erreur est survenue lors du traitement de votre demande.")
  317. @bot.event
  318. async def on_message(message):
  319. if message.author == bot.user:
  320. return
  321. if bot.user.mentioned_in(message):
  322. if message.channel.id == CHANNEL_ID:
  323. pass
  324. else:
  325. await handle_bot_mention(message)
  326. return
  327. if message.channel.id != CHANNEL_ID:
  328. return
  329. if await handle_stickers(message):
  330. return
  331. if await handle_emojis(message):
  332. return
  333. if not await handle_images(message):
  334. return
  335. channel_id = str(message.channel.id)
  336. history = history_manager.get_history(channel_id)
  337. image_url = None
  338. if message.attachments:
  339. for attachment in message.attachments:
  340. if attachment.content_type and attachment.content_type.startswith('image/'):
  341. image_url = attachment.url
  342. break
  343. resolved_content = message.content
  344. for user in message.mentions:
  345. resolved_content = resolved_content.replace(f"<@{user.id}>", f"@{user.display_name}")
  346. prompt = resolved_content
  347. async with message.channel.typing():
  348. try:
  349. response = call_mistral_api(
  350. prompt,
  351. history,
  352. image_url,
  353. user_id=str(message.author.id),
  354. username=message.author.display_name
  355. )
  356. await message.channel.send(response)
  357. except Exception as e:
  358. logger.error(f"Erreur lors de l'appel à l'API: {e}")
  359. await message.channel.send("Désolé, une erreur est survenue lors du traitement de votre demande.")
  360. await bot.process_commands(message)
  361. bot.run(DISCORD_TOKEN)