chatbot.py 20 KB

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