chatbot.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import os
  2. import logging
  3. import discord
  4. import json
  5. import urllib3
  6. import base64
  7. import pytz
  8. from dotenv import load_dotenv
  9. from openai import AsyncOpenAI, OpenAIError
  10. from PIL import Image
  11. from io import BytesIO
  12. from datetime import datetime, timezone, timedelta
  13. # Charger les variables d'environnement depuis le fichier .env
  14. load_dotenv()
  15. DISCORD_TOKEN = os.getenv('DISCORD_TOKEN')
  16. OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
  17. DISCORD_CHANNEL_ID = os.getenv('DISCORD_CHANNEL_ID')
  18. PERSONALITY_PROMPT_FILE = os.getenv('PERSONALITY_PROMPT_FILE', 'personality_prompt.txt')
  19. CONVERSATION_HISTORY_FILE = os.getenv('CONVERSATION_HISTORY_FILE', 'conversation_history.json')
  20. CONVERSATION_HISTORY_SIZE = int(os.getenv('CONVERSATION_HISTORY_SIZE', '100'))
  21. BOT_NAME = os.getenv('BOT_NAME', 'ChatBot')
  22. MODEL = os.getenv('MODEL', 'llama:3.2')
  23. URL_OPENAI_API = os.getenv('URL_OPENAI_API', 'http://localai.localai.svc.cluster.local:8080/v1')
  24. TEMPERATURE = float(os.getenv('TEMPERATURE', "1.0"))
  25. # Initialiser le client OpenAI asynchrone ici
  26. openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY, base_url=URL_OPENAI_API)
  27. BOT_VERSION = "2.8.0-beta"
  28. # Vérifier que les tokens et le prompt de personnalité sont récupérés
  29. if DISCORD_TOKEN is None or OPENAI_API_KEY is None or DISCORD_CHANNEL_ID is None:
  30. raise ValueError("Les tokens ou l'ID du canal ne sont pas définis dans les variables d'environnement.")
  31. if not os.path.isfile(PERSONALITY_PROMPT_FILE):
  32. raise FileNotFoundError(f"Le fichier de prompt de personnalité '{PERSONALITY_PROMPT_FILE}' est introuvable.")
  33. # Lire le prompt de personnalité depuis le fichier
  34. with open(PERSONALITY_PROMPT_FILE, 'r', encoding='utf-8') as f:
  35. PERSONALITY_PROMPT = f.read().strip()
  36. # Log configuration
  37. log_format = '%(asctime)-13s : %(name)-15s : %(levelname)-8s : %(message)s'
  38. logging.basicConfig(handlers=[logging.FileHandler("./chatbot.log", 'a', 'utf-8')], format=log_format, level="INFO")
  39. console = logging.StreamHandler()
  40. console.setLevel(logging.DEBUG)
  41. console.setFormatter(logging.Formatter(log_format))
  42. logger = logging.getLogger(BOT_NAME)
  43. logger.setLevel("DEBUG")
  44. logging.getLogger('').addHandler(console)
  45. httpx_logger = logging.getLogger('httpx')
  46. httpx_logger.setLevel(logging.DEBUG)
  47. urllib3.disable_warnings()
  48. # Initialiser les intents
  49. intents = discord.Intents.default()
  50. intents.message_content = True # Activer l'intent pour les contenus de message
  51. # Liste pour stocker l'historique des conversations
  52. conversation_history = []
  53. def filter_message(message):
  54. """Filtre le contenu d'un retour de modèle de language, comme pour enlever les pensées dans le cas de DeepSeek"""
  55. if len(message.split('</think>')) > 1:
  56. result = message.split('</think>')[1]
  57. elif len(message.split('</response>')) > 1:
  58. result = message.split('</response>')[1]
  59. else:
  60. result = message
  61. result.rstrip("</s>")
  62. return result
  63. def transform_emote(message: str, output: bool) -> str:
  64. """Remplace les smileys par les codes Discord correspondant"""
  65. list_emote = [
  66. (":hap:", "<:hap:355854929073537026>"),
  67. (":angryvault:", "<:angryvault:585550568806940672>"),
  68. (":minou:", "<:minou:358054423462936576>"),
  69. (":cetaitsur:", "<a:cetaitsur:826102032963469324>"),
  70. (":eh:", "<:eh:395979132896280576>"),
  71. (":desu:", "<:desu:388007643077410837>"),
  72. (":bave2:", "<:bave2:412252920558387221>"),
  73. (":haptriste:", "<:haptriste:358054014262181889>"),
  74. (":perplexe:", "<:perplexe:358054891274371082>"),
  75. (":sueur:", "<:sueur:358051940631838721>"),
  76. (":chien:", "<:chien:507606737646518293>"),
  77. (":kemar:", "<:kemar:419607012796792842>"),
  78. (":ouch2:", "<:ouch2:777984650710745138>"),
  79. (":coeur:", "<:coeur:355853389399195649>"),
  80. (":what:", "<:what:587019571207077928>")
  81. ]
  82. for smiley, discord_code in list_emote:
  83. if output:
  84. message = message.replace(smiley, discord_code)
  85. else:
  86. message = message.replace(discord_code, smiley)
  87. return message
  88. def split_message(message, max_length=2000):
  89. """Divise un message en plusieurs segments de longueur maximale spécifiée."""
  90. if len(message) <= max_length:
  91. return [message]
  92. parts = []
  93. current_part = ""
  94. for line in message.split('\n'):
  95. if len(current_part) + len(line) + 1 > max_length:
  96. parts.append(current_part)
  97. current_part = line + '\n'
  98. else:
  99. current_part += line + '\n'
  100. if current_part:
  101. parts.append(current_part)
  102. return parts
  103. def load_conversation_history():
  104. global conversation_history
  105. if os.path.isfile(CONVERSATION_HISTORY_FILE):
  106. try:
  107. with open(CONVERSATION_HISTORY_FILE, 'r', encoding='utf-8') as f:
  108. loaded_history = json.load(f)
  109. # Exclure uniquement le PERSONALITY_PROMPT
  110. conversation_history = [
  111. msg for msg in loaded_history
  112. if not (msg.get("role") == "system" and msg.get("content") == PERSONALITY_PROMPT)
  113. ]
  114. logger.info(f"Historique chargé depuis {CONVERSATION_HISTORY_FILE}")
  115. except Exception as e:
  116. logger.error(f"Erreur lors du chargement de l'historique : {e}")
  117. conversation_history = []
  118. else:
  119. logger.info(f"Aucun fichier d'historique trouvé. Un nouveau fichier sera créé à {CONVERSATION_HISTORY_FILE}")
  120. def has_text(text):
  121. """
  122. Détermine si le texte fourni est non vide après suppression des espaces.
  123. """
  124. return bool(text.strip())
  125. def resize_image(image_bytes, mode='high', attachment_filename=None):
  126. """Redimensionne l'image selon le mode spécifié."""
  127. try:
  128. with Image.open(BytesIO(image_bytes)) as img:
  129. original_format = img.format # Stocker le format original
  130. if mode == 'high':
  131. img.thumbnail((2000, 2000))
  132. if min(img.size) < 768:
  133. scale = 768 / min(img.size)
  134. new_size = tuple(int(x * scale) for x in img.size)
  135. img = img.resize(new_size, Image.Resampling.LANCZOS)
  136. elif mode == 'low':
  137. img = img.resize((512, 512))
  138. buffer = BytesIO()
  139. img_format = img.format or _infer_image_format(attachment_filename)
  140. img.save(buffer, format=img_format)
  141. return buffer.getvalue()
  142. except Exception as e:
  143. logger.error(f"Erreur lors du redimensionnement de l'image : {e}")
  144. raise
  145. async def encode_image_from_attachment(attachment, mode='high'):
  146. """Encode une image depuis une pièce jointe en base64 après redimensionnement."""
  147. image_data = await attachment.read()
  148. resized_image = resize_image(image_data, mode=mode, attachment_filename=attachment.filename)
  149. return base64.b64encode(resized_image).decode('utf-8')
  150. def _infer_image_format(filename):
  151. """Déduit le format de l'image basé sur l'extension du fichier."""
  152. if filename:
  153. _, ext = os.path.splitext(filename)
  154. ext = ext.lower()
  155. format_mapping = {
  156. '.jpg': 'JPEG',
  157. '.jpeg': 'JPEG',
  158. '.png': 'PNG',
  159. '.gif': 'GIF',
  160. '.bmp': 'BMP',
  161. '.tiff': 'TIFF'
  162. }
  163. return format_mapping.get(ext, 'PNG')
  164. return 'PNG'
  165. # Fonction de sauvegarde de l'historique
  166. def save_conversation_history():
  167. try:
  168. with open(CONVERSATION_HISTORY_FILE, 'w', encoding='utf-8') as f:
  169. json.dump(conversation_history, f, ensure_ascii=False, indent=4)
  170. except Exception as e:
  171. logger.error(f"Erreur lors de la sauvegarde de l'historique : {e}")
  172. # Convertir l'ID du channel en entier
  173. try:
  174. discord_channel_id = int(DISCORD_CHANNEL_ID)
  175. except ValueError:
  176. raise ValueError("L'ID du channel Discord est invalide. Assurez-vous qu'il s'agit d'un entier.")
  177. class MyDiscordClient(discord.Client):
  178. async def close(self):
  179. global openai_client
  180. if openai_client is not None:
  181. await openai_client.close()
  182. openai_client = None
  183. await super().close()
  184. # Initialiser le client Discord avec les intents modifiés
  185. client_discord = MyDiscordClient(intents=intents)
  186. # Appeler la fonction pour charger l'historique au démarrage
  187. load_conversation_history()
  188. async def call_for_image_analysis(image_data, user_name, user_text=None, detail='high'):
  189. """Appelle l'API pour analyser une image."""
  190. prompt = PERSONALITY_PROMPT
  191. if user_text:
  192. user_content = f"{user_name} a envoyé une image avec le messsage : \"{user_text}\"."
  193. else:
  194. user_content = f"{user_name} a envoyé une image:"
  195. prompt += user_content
  196. message_to_send = {
  197. "role": "user",
  198. "content": [
  199. {"type": "text", "text": prompt},
  200. {
  201. "type": "image_url",
  202. "image_url": {
  203. "url": f"data:image/jpeg;base64,{image_data}",
  204. "detail": detail
  205. }
  206. }
  207. ]
  208. }
  209. user_message = {"role": "user", "content": user_content}
  210. messages = [user_message] + [message_to_send] + conversation_history
  211. analysis = await openai_client.chat.completions.create(
  212. model=MODEL,
  213. messages=messages,
  214. temperature=TEMPERATURE
  215. )
  216. if analysis:
  217. logger.info(f"Analyse de l'image par l'API : {analysis.choices[0].message.content}")
  218. return analysis.choices[0].message.content
  219. async def call_openai_api(user_text, user_name, detail='high'):
  220. # Préparer le contenu pour l'appel API
  221. message_to_send = {
  222. "role": "user",
  223. "content": [
  224. {"type": "text", "text": f"{user_name} dit : {transform_emote(user_text, False)}"}
  225. ]
  226. }
  227. # Assembler les messages avec le prompt de personnalité en premier
  228. messages = [
  229. {"role": "system", "content": PERSONALITY_PROMPT}
  230. ] + conversation_history + [message_to_send]
  231. try:
  232. response = await openai_client.chat.completions.create(
  233. model=MODEL,
  234. messages=messages,
  235. temperature=TEMPERATURE
  236. )
  237. if response:
  238. reply = response.choices[0].message.content
  239. await add_to_conversation_history(message_to_send)
  240. # Ajouter la réponse de l'IA directement à l'historique
  241. await add_to_conversation_history({
  242. "role": "assistant",
  243. "content": reply
  244. })
  245. return response
  246. except Exception as e:
  247. logger.error(f"Erreur durant l'appel de l'API : {e}")
  248. return None
  249. @client_discord.event
  250. async def on_ready():
  251. logger.info(f'{BOT_NAME} connecté en tant que {client_discord.user}')
  252. logger.info(f'Utilisation du modèle {MODEL}')
  253. if not conversation_history:
  254. logger.info("Aucun historique trouvé. L'historique commence vide.")
  255. # Envoyer un message de version dans le canal Discord
  256. channel = client_discord.get_channel(discord_channel_id)
  257. if channel:
  258. try:
  259. embed = discord.Embed(
  260. title=f"Bot Démarré",
  261. description=f"🎉 {BOT_NAME} est en ligne ! Version {BOT_VERSION}\nUtilisation du modèle: **{MODEL}**",
  262. color=0x2222aa # Bleu
  263. )
  264. await channel.send(embed=embed)
  265. logger.info(f"Message de connexion envoyé dans le canal ID {discord_channel_id}")
  266. except discord.Forbidden:
  267. logger.error(f"Permissions insuffisantes pour envoyer des messages dans le canal ID {discord_channel_id}.")
  268. except discord.HTTPException as e:
  269. logger.error(f"Erreur lors de l'envoi du message de connexion : {e}")
  270. else:
  271. logger.error(f"Canal avec ID {discord_channel_id} non trouvé.")
  272. @client_discord.event
  273. async def on_message(message):
  274. global conversation_history
  275. # Vérifier si le message provient du canal autorisé
  276. if message.channel.id != discord_channel_id:
  277. return
  278. # Ignorer les messages du bot lui-même
  279. if message.author == client_discord.user:
  280. return
  281. user_text = message.content.strip()
  282. file_content = None
  283. attachment_filename = None
  284. # Vérifier si le message est la commande de réinitialisation
  285. if user_text.lower() == "!reset_history":
  286. # Vérifier si l'utilisateur a les permissions administratives
  287. if not message.author.guild_permissions.administrator:
  288. await message.channel.send("❌ Vous n'avez pas la permission d'utiliser cette commande.")
  289. return
  290. conversation_history = []
  291. save_conversation_history()
  292. await message.channel.send("✅ L'historique des conversations a été réinitialisé.")
  293. logger.info(f"Historique des conversations réinitialisé par {message.author}.")
  294. return # Arrêter le traitement du message après la réinitialisation
  295. # Extensions de fichiers autorisées
  296. allowed_extensions = ['.txt', '.py', '.html', '.css', '.js']
  297. # Variable pour stocker si le message contient un fichier
  298. has_file = False
  299. image_data = None
  300. # Vérifier s'il y a une pièce jointe
  301. if message.attachments:
  302. for attachment in message.attachments:
  303. # Vérifier si c'est un fichier avec une extension autorisée
  304. if any(attachment.filename.endswith(ext) for ext in allowed_extensions):
  305. file_content = await read_text_file(attachment)
  306. attachment_filename = attachment.filename
  307. break
  308. elif attachment.content_type and attachment.content_type.startswith('image/'):
  309. image_data = await encode_image_from_attachment(attachment, mode='high')
  310. break
  311. # Traitement des images
  312. if image_data:
  313. logger.debug(image_data)
  314. has_user_text = has_text(user_text)
  315. user_text_to_use = user_text if has_user_text else None
  316. temp_msg = await message.channel.send(f"*{BOT_NAME} observe l'image...*")
  317. try:
  318. # Analyser l'image avec l'API
  319. analysis = await call_for_image_analysis(image_data, message.author.name, user_text=user_text_to_use)
  320. if analysis:
  321. await temp_msg.delete()
  322. await message.channel.send(analysis)
  323. if has_user_text:
  324. user_message_text = f"{message.author.name} a envoyé une image avec le messsage : \"{user_text}\"."
  325. else:
  326. user_message_text = f"{message.author.name} a envoyé une image."
  327. user_message = {
  328. "role": "user",
  329. "content": f"{user_message_text}"
  330. }
  331. assistant_message = {
  332. "role": "assistant",
  333. "content": analysis
  334. }
  335. await add_to_conversation_history(user_message)
  336. await add_to_conversation_history(assistant_message)
  337. else:
  338. await temp_msg.delete()
  339. await message.channel.send("Désolé, je n'ai pas pu analyser l'image.")
  340. except Exception as e:
  341. await temp_msg.delete()
  342. await message.channel.send("Une erreur est survenue lors du traitement de l'image.")
  343. logger.error(f"Erreur lors du traitement de l'image: {e}")
  344. return # Ne pas continuer le traitement après une image
  345. # Ajouter le contenu du fichier à la requête si présent
  346. if file_content:
  347. user_text += f"\nContenu du fichier {attachment.filename}:\n{file_content}"
  348. # Vérifier si le texte n'est pas vide après ajout du contenu du fichier
  349. if not has_text(user_text):
  350. return # Ne pas appeler l'API si le texte est vide
  351. async with message.channel.typing():
  352. try:
  353. # Appeler l'API
  354. result = await call_openai_api(user_text, message.author.name)
  355. if result:
  356. reply = result.choices[0].message.content
  357. reply = filter_message(reply)
  358. reply = transform_emote(reply, True)
  359. message_parts = split_message(reply)
  360. for part in message_parts:
  361. await message.channel.send(part)
  362. # Afficher dans la console
  363. logging.info(f"Réponse envoyée. ({len(message_parts)} message(s))")
  364. except Exception as e:
  365. await message.channel.send("Franchement, je sais pas quoi te répondre. <:haptriste:358054014262181889>")
  366. logger.error(f"Erreur lors du traitement du texte: {e}")
  367. async def add_to_conversation_history(new_message):
  368. global conversation_history
  369. # Ne pas ajouter le PERSONALITY_PROMPT à l'historique
  370. if new_message.get("role") == "system" and new_message.get("content") == PERSONALITY_PROMPT:
  371. logger.debug("PERSONALITY_PROMPT système non ajouté à l'historique.")
  372. return
  373. conversation_history.append(new_message)
  374. save_conversation_history()
  375. logger.debug(f"Message ajouté à l'historique. Taille actuelle : {len(conversation_history)}")
  376. if len(conversation_history) > CONVERSATION_HISTORY_SIZE:
  377. logger.info(f"Limite de {CONVERSATION_HISTORY_SIZE} messages atteinte.")
  378. excess_messages = len(conversation_history) - CONVERSATION_HISTORY_SIZE
  379. if excess_messages > 0:
  380. # Supprimer les messages les plus anciens
  381. del conversation_history[:excess_messages]
  382. save_conversation_history()
  383. logger.info(f"{excess_messages} messages les plus anciens ont été supprimés pour maintenir l'historique à {CONVERSATION_HISTORY_SIZE} messages.")
  384. # Démarrer le bot Discord
  385. client_discord.run(DISCORD_TOKEN)