chatbot.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import os
  2. import base64
  3. import logging
  4. import re
  5. from io import BytesIO
  6. import discord
  7. from dotenv import load_dotenv
  8. from PIL import Image
  9. import emoji
  10. import tiktoken
  11. from openai import AsyncOpenAI, OpenAIError
  12. # Charger les variables d'environnement depuis le fichier .env
  13. load_dotenv()
  14. DISCORD_TOKEN = os.getenv('DISCORD_TOKEN')
  15. OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
  16. DISCORD_CHANNEL_ID = os.getenv('DISCORD_CHANNEL_ID')
  17. # Chemin vers le fichier de prompt de personnalité
  18. PERSONALITY_PROMPT_FILE = os.getenv('PERSONALITY_PROMPT_FILE', 'personality_prompt.txt')
  19. # Vérifier que les tokens et le prompt de personnalité sont récupérés
  20. if DISCORD_TOKEN is None or OPENAI_API_KEY is None or DISCORD_CHANNEL_ID is None:
  21. raise ValueError("Les tokens ou l'ID du canal ne sont pas définis dans les variables d'environnement.")
  22. if not os.path.isfile(PERSONALITY_PROMPT_FILE):
  23. raise FileNotFoundError(f"Le fichier de prompt de personnalité '{PERSONALITY_PROMPT_FILE}' est introuvable.")
  24. # Lire le prompt de personnalité depuis le fichier
  25. with open(PERSONALITY_PROMPT_FILE, 'r', encoding='utf-8') as f:
  26. PERSONALITY_PROMPT = f.read().strip()
  27. # Log configuration
  28. log_format = '%(asctime)-13s : %(name)-15s : %(levelname)-8s : %(message)s'
  29. logging.basicConfig(handlers=[logging.FileHandler("./chatbot.log", 'a', 'utf-8')], format=log_format, level="INFO")
  30. console = logging.StreamHandler()
  31. console.setLevel(logging.INFO)
  32. console.setFormatter(logging.Formatter(log_format))
  33. logger = logging.getLogger("chatbot")
  34. logger.setLevel("INFO")
  35. logging.getLogger('').addHandler(console)
  36. httpx_logger = logging.getLogger('httpx')
  37. httpx_logger.setLevel(logging.WARNING)
  38. # Initialiser les intents
  39. intents = discord.Intents.default()
  40. intents.message_content = True # Activer l'intent pour les contenus de message
  41. # Liste pour stocker l'historique des conversations
  42. conversation_history = []
  43. # Convertir l'ID du channel en entier
  44. try:
  45. chatgpt_channel_id = int(DISCORD_CHANNEL_ID)
  46. except ValueError:
  47. raise ValueError("L'ID du channel Discord est invalide. Assurez-vous qu'il s'agit d'un entier.")
  48. """Module contenant un bot Discord utilisant l'API OpenAI."""
  49. class MyDiscordClient(discord.Client):
  50. """Classe personnalisée pour le client Discord."""
  51. async def close(self):
  52. """Ferme le client Discord et OpenAI proprement."""
  53. global openai_client
  54. if openai_client is not None:
  55. await openai_client.close()
  56. openai_client = None
  57. await super().close()
  58. # Initialiser le client Discord avec les intents modifiés
  59. client_discord = MyDiscordClient(intents=intents)
  60. # Initialiser le client OpenAI asynchrone
  61. openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY)
  62. # Charger l'encodeur pour le modèle GPT-4o
  63. encoding = tiktoken.get_encoding("o200k_base")
  64. def resize_image(image_bytes, mode='high'):
  65. with Image.open(BytesIO(image_bytes)) as img:
  66. if mode == 'high':
  67. # Redimensionner pour le mode haute fidélité
  68. img.thumbnail((2000, 2000))
  69. if min(img.size) < 768:
  70. scale = 768 / min(img.size)
  71. new_size = tuple(int(x * scale) for x in img.size)
  72. img = img.resize(new_size, Image.Resampling.LANCZOS)
  73. elif mode == 'low':
  74. # Redimensionner pour le mode basse fidélité
  75. img = img.resize((512, 512))
  76. buffer = BytesIO()
  77. img.save(buffer, format=img.format)
  78. return buffer.getvalue()
  79. def contains_ascii_art(text):
  80. """
  81. Détecte la présence d'au moins un bloc d'ASCII art dans le texte.
  82. Un bloc d'ASCII art est défini par un minimum de lignes avec une densité élevée de caractères spéciaux.
  83. """
  84. lines = text.split('\n')
  85. current_block = []
  86. detected = False
  87. density_threshold = 0.2 # Proportion minimale de caractères spéciaux
  88. min_lines = 3 # Nombre minimum de lignes pour un bloc d'ASCII art
  89. for line in lines:
  90. if line.strip() == '':
  91. # Fin d'un bloc potentiel
  92. if len(current_block) >= min_lines and block_is_ascii_art(current_block, density_threshold):
  93. detected = True
  94. break
  95. current_block = []
  96. else:
  97. current_block.append(line)
  98. # Vérifier le dernier bloc
  99. if not detected and len(current_block) >= min_lines and block_is_ascii_art(current_block, density_threshold):
  100. detected = True
  101. return detected
  102. def block_is_ascii_art(block, density_threshold):
  103. """
  104. Évalue si un bloc de lignes correspond aux critères d'un dessin ASCII.
  105. """
  106. special_char_count = sum(len(re.findall(r'[^\w\s]', line)) for line in block)
  107. total_chars = sum(len(line) for line in block)
  108. if total_chars == 0:
  109. return False
  110. density = special_char_count / total_chars
  111. if density < density_threshold:
  112. return False
  113. average_length = sum(len(line) for line in block) / len(block)
  114. similar_length_lines = sum(1 for line in block if abs(len(line) - average_length) < 5)
  115. if similar_length_lines >= len(block) * 0.8:
  116. return True
  117. return False
  118. def is_long_special_text(text):
  119. # Vérifier que le texte est bien une chaîne de caractères
  120. if not isinstance(text, str):
  121. logger.error(f"Erreur : Le contenu n'est pas une chaîne valide. Contenu : {text}")
  122. return False
  123. # Compter le nombre de tokens dans le texte
  124. token_count = len(encoding.encode(text))
  125. # Définir un seuil pour considérer le texte comme long
  126. if token_count > 200:
  127. logger.info("Texte long détecté : %d tokens", token_count)
  128. return True
  129. return False
  130. def extract_text_from_message(message):
  131. content = message.get("content", "")
  132. if isinstance(content, list):
  133. # Extraire le texte de chaque élément de la liste
  134. texts = []
  135. for part in content:
  136. if isinstance(part, dict):
  137. text = part.get("text", "")
  138. if text:
  139. texts.append(text)
  140. return ' '.join(texts)
  141. elif isinstance(content, str):
  142. return content
  143. else:
  144. return ""
  145. def calculate_cost(usage):
  146. input_tokens = usage.get('prompt_tokens', 0)
  147. output_tokens = usage.get('completion_tokens', 0)
  148. # Coûts estimés
  149. input_cost = input_tokens / 1_000_000 * 5.00 # 5$ pour 1M tokens d'entrée
  150. output_cost = output_tokens / 1_000_000 * 15.00 # 15$ pour 1M tokens de sortie
  151. total_cost = input_cost + output_cost
  152. return input_tokens, output_tokens, total_cost
  153. def is_relevant_message(message):
  154. content = message["content"]
  155. if isinstance(content, list):
  156. content = ''.join(part.get('text', '') for part in content if 'text' in part)
  157. if len(content.strip()) < 5:
  158. return False
  159. discord_emoji_pattern = r'<a?:\w+:\d+>'
  160. def is_discord_emoji(part):
  161. return bool(re.fullmatch(discord_emoji_pattern, part))
  162. tokens = re.split(discord_emoji_pattern, content)
  163. emojis_only = True
  164. standard_emojis = [char for char in content if emoji.is_emoji(char)]
  165. discord_emojis = re.findall(discord_emoji_pattern, content)
  166. text_without_emojis = re.sub(discord_emoji_pattern, '', content)
  167. for char in text_without_emojis:
  168. if not char.isspace() and not emoji.is_emoji(char):
  169. emojis_only = False
  170. break
  171. if len(standard_emojis) + len(discord_emojis) == 0:
  172. emojis_only = False
  173. if emojis_only and len(content.strip()) > 0:
  174. return False
  175. return True
  176. async def read_text_file(attachment):
  177. file_bytes = await attachment.read()
  178. return file_bytes.decode('utf-8')
  179. async def encode_image_from_attachment(attachment, mode='high'):
  180. image_data = await attachment.read()
  181. resized_image = resize_image(image_data, mode=mode)
  182. return base64.b64encode(resized_image).decode('utf-8')
  183. async def summarize_text(text, max_tokens=50):
  184. summary_prompt = f"Résumé :\n\n{text}\n\nRésumé:"
  185. try:
  186. response = await openai_client.chat.completions.create(
  187. model="gpt-4o",
  188. messages=[
  189. {"role": "system", "content": "You are a helpful assistant."},
  190. {"role": "user", "content": summary_prompt}
  191. ],
  192. max_tokens=max_tokens # Limitez les tokens pour obtenir un résumé court
  193. )
  194. summary = response.choices[0].message.content.strip()
  195. if hasattr(response, 'usage'):
  196. usage_dict = {
  197. 'prompt_tokens': response.usage.prompt_tokens,
  198. 'completion_tokens': response.usage.completion_tokens
  199. }
  200. else:
  201. usage_dict = {}
  202. return summary, usage_dict
  203. except OpenAIError as e:
  204. logger.error(f"Error summarizing text: {e}")
  205. return text, {}
  206. except AttributeError as e:
  207. logger.error(f"Attribute error during summarization: {e}")
  208. return text, {}
  209. async def call_openai_api(user_text, user_name, image_data=None, detail='high'):
  210. # Préparer le contenu pour l'appel API
  211. message_to_send = {
  212. "role": "user",
  213. "content": [
  214. {"type": "text", "text": f"{user_name} dit : {user_text}"}
  215. ]
  216. }
  217. # Inclure l'image dans l'appel API courant
  218. if image_data:
  219. message_to_send["content"].append({
  220. "type": "image_url",
  221. "image_url": {
  222. "url": f"data:image/jpeg;base64,{image_data}",
  223. "detail": detail
  224. }
  225. })
  226. try:
  227. response = await openai_client.chat.completions.create(
  228. model="gpt-4o",
  229. messages=conversation_history + [message_to_send],
  230. max_tokens=400
  231. )
  232. if response:
  233. reply = response.choices[0].message.content
  234. # Ajouter le message de l'utilisateur à l'historique global, mais uniquement s'il ne s'agit pas d'une image ou d'ASCII art
  235. if image_data is None and not contains_ascii_art(user_text):
  236. await add_to_conversation_history(message_to_send)
  237. # Ajouter la réponse de l'IA directement à l'historique
  238. await add_to_conversation_history({
  239. "role": "assistant",
  240. "content": reply
  241. })
  242. if hasattr(response, 'usage') and response.usage:
  243. usage = response.usage
  244. input_tokens, output_tokens, total_cost = calculate_cost({
  245. 'prompt_tokens': usage.prompt_tokens,
  246. 'completion_tokens': usage.completion_tokens
  247. })
  248. # Afficher dans la console
  249. logging.info(f"Coût de la réponse : ${total_cost:.4f} / Input: {input_tokens} / Output: {output_tokens} / Total: {input_tokens + output_tokens}")
  250. return response
  251. except OpenAIError as e:
  252. logger.error(f"Error calling OpenAI API: {e}")
  253. except Exception as e:
  254. logger.error(f"Error calling OpenAI API: {e}")
  255. return None
  256. @client_discord.event
  257. async def on_ready():
  258. logger.info(f'Bot connecté en tant que {client_discord.user}')
  259. # Ajouter la personnalité de l'IA à l'historique au démarrage
  260. if not conversation_history:
  261. conversation_history.append({
  262. "role": "system",
  263. "content": PERSONALITY_PROMPT
  264. })
  265. @client_discord.event
  266. async def on_disconnect():
  267. await client_discord.close()
  268. @client_discord.event
  269. async def on_message(message):
  270. # Vérifier si le message provient du canal autorisé
  271. if message.channel.id != chatgpt_channel_id:
  272. return
  273. # Vérifier si l'auteur du message est le bot lui-même
  274. if message.author == client_discord.user:
  275. return
  276. user_text = message.content.strip()
  277. image_data = None
  278. file_content = None
  279. # Vérifier si le message contient un dessin ASCII
  280. if contains_ascii_art(user_text):
  281. logger.info(f"Dessin ASCII détecté de {message.author.name}")
  282. # Extensions de fichiers autorisées
  283. allowed_extensions = ['.txt', '.py', '.html', '.css', '.js']
  284. # Vérifier s'il y a une pièce jointe
  285. if message.attachments:
  286. for attachment in message.attachments:
  287. # Vérifier si c'est un fichier avec une extension autorisée
  288. if any(attachment.filename.endswith(ext) for ext in allowed_extensions):
  289. file_content = await read_text_file(attachment)
  290. break
  291. # Vérifier si c'est une image
  292. elif attachment.content_type.startswith('image'):
  293. image_data = await encode_image_from_attachment(attachment, mode='high')
  294. break
  295. # Ajouter le contenu du fichier à la requête si présent
  296. if file_content:
  297. user_text += f"\nContenu du fichier {attachment.filename}:\n{file_content}"
  298. # Appeler l'API OpenAI
  299. result = await call_openai_api(user_text, message.author.name, image_data)
  300. if result:
  301. reply = result.choices[0].message.content
  302. await message.channel.send(reply)
  303. async def add_to_conversation_history(new_message):
  304. # Extraire le texte du message
  305. if isinstance(new_message["content"], list) and len(new_message["content"]) > 0:
  306. content_text = new_message["content"][0].get("text", "")
  307. else:
  308. content_text = new_message.get("content", "")
  309. if not isinstance(content_text, str):
  310. logger.error(f"Erreur : Le contenu n'est pas une chaîne valide. Contenu : {content_text}")
  311. return
  312. if is_long_special_text(content_text):
  313. summary, usage = await summarize_text(content_text)
  314. new_message = {
  315. "role": new_message["role"],
  316. "content": summary
  317. }
  318. # Inclure le coût du résumé dans le calcul total
  319. input_tokens, output_tokens, total_cost = calculate_cost(usage)
  320. logging.info(f"Coût du résumé : ${total_cost:.4f} / Input: {input_tokens} / Output: {output_tokens} / Total: {input_tokens + output_tokens}")
  321. # Filtrer les messages pertinents pour l'historique
  322. if is_relevant_message(new_message):
  323. # Ajouter le message à l'historique
  324. conversation_history.append(new_message)
  325. # Synthétiser les messages les plus anciens si l'historique est trop long
  326. if len(conversation_history) > 30:
  327. # Synthétiser les 20 plus anciens messages (exclure la personnalité et les 10 plus récents)
  328. messages_to_summarize = conversation_history[1:21] # Exclure le premier (personnalité)
  329. texts = [extract_text_from_message(msg) for msg in messages_to_summarize]
  330. texts = [text for text in texts if text]
  331. combined_text = ' '.join(texts)
  332. combined_token_count = len(encoding.encode(combined_text))
  333. if combined_token_count > 15000:
  334. encoded_text = encoding.encode(combined_text)
  335. truncated_text = encoding.decode(encoded_text[:500])
  336. combined_text = truncated_text
  337. logger.info(f"Combined text tronqué à 15 000 tokens.")
  338. synthesized_summary, usage = await summarize_text(combined_text, max_tokens=400)
  339. # Calculer le coût de la synthèse
  340. input_tokens, output_tokens, total_cost = calculate_cost(usage)
  341. logging.info(f"30 messages dans l'historique. Synthèse effectuée. Coût : ${total_cost:.4f} / Input: {input_tokens} / Output: {output_tokens} / Total: {input_tokens + output_tokens}")
  342. # Remplacer l'ancienne synthèse par la nouvelle
  343. # Conserver la personnalité et la nouvelle synthèse
  344. conversation_history[:] = [conversation_history[0], {"role": "system", "content": synthesized_summary}] + conversation_history[21:]
  345. # Démarrer le bot Discord
  346. client_discord.run(DISCORD_TOKEN)