chatbot.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781
  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. import asyncio
  11. import websockets
  12. import numpy as np
  13. import time
  14. import signal
  15. import sys
  16. from dotenv import load_dotenv
  17. from openai import AsyncOpenAI, OpenAIError
  18. from PIL import Image
  19. from io import BytesIO
  20. from datetime import datetime, timezone, timedelta
  21. from charset_normalizer import from_bytes
  22. from enum import Enum
  23. from discord.sinks import Sink
  24. from scipy.signal import resample_poly
  25. from discord.ext import tasks
  26. # Charger les variables d'environnement depuis le fichier .env
  27. load_dotenv()
  28. DISCORD_TOKEN = os.getenv('DISCORD_TOKEN')
  29. OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
  30. DISCORD_CHANNEL_ID = os.getenv('DISCORD_CHANNEL_ID')
  31. PERSONALITY_PROMPT_FILE = os.getenv('PERSONALITY_PROMPT_FILE', 'personality_prompt.txt')
  32. CONVERSATION_HISTORY_FILE = os.getenv('CONVERSATION_HISTORY_FILE', 'conversation_history.json')
  33. CONVERSATION_HISTORY_SIZE = int(os.getenv('CONVERSATION_HISTORY_SIZE', '100'))
  34. BOT_NAME = os.getenv('BOT_NAME', 'ChatBot')
  35. MODEL = os.getenv('MODEL', 'llama:3.2')
  36. URL_OPENAI_API = os.getenv('URL_OPENAI_API', 'http://openwebui:8080/v1')
  37. TEMPERATURE = float(os.getenv('TEMPERATURE', "1.0"))
  38. BOOT_MESSAGE = os.getenv('BOOT_MESSAGE', "true").lower()
  39. LOG_LEVEL = os.getenv('LOG_LEVEL', "INFO").upper()
  40. HISTORY_ANALYSIS_IMAGE = os.getenv('HISTORY_ANALYSIS_IMAGE', "false").lower()
  41. PROMPT_STATUS_CHANGE = str(os.getenv('PROMPT_STATUS_CHANGE', "Rédige un message court qui sera utilisé en tant que status sur Discord"))
  42. DELAY_TASK_UPDATE_STATUS = int(os.getenv('DELAY_TASK_UPDATE_STATUS', '30'))
  43. WHISPER_WS_URL = os.getenv("WHISPER_WS_URL", "ws://whisper-stt:8000/ws/transcribe")
  44. # Initialiser le client OpenAI asynchrone ici
  45. openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY, base_url=URL_OPENAI_API)
  46. BOT_VERSION = "3.0.0-alpha2"
  47. # Vérifier la version de Discord
  48. if not sys.version_info[:2] >= (3, 13):
  49. print("ERROR: Requires python 3.13 or newer.")
  50. exit(1)
  51. # Vérifier que les tokens et le prompt de personnalité sont récupérés
  52. if DISCORD_TOKEN is None or OPENAI_API_KEY is None or DISCORD_CHANNEL_ID is None:
  53. raise ValueError("Les tokens ou l'ID du canal ne sont pas définis dans les variables d'environnement.")
  54. if not os.path.isfile(PERSONALITY_PROMPT_FILE):
  55. raise FileNotFoundError(f"Le fichier de prompt de personnalité '{PERSONALITY_PROMPT_FILE}' est introuvable.")
  56. # Lire le prompt de personnalité depuis le fichier
  57. with open(PERSONALITY_PROMPT_FILE, 'r', encoding='utf-8') as f:
  58. PERSONALITY_PROMPT = f.read().strip()
  59. # Log configuration
  60. log_format = '%(asctime)-13s : %(name)-25s : %(levelname)-8s : %(message)s'
  61. logging.basicConfig(handlers=[logging.FileHandler("./chatbot.log", 'a', 'utf-8')], format=log_format, level=LOG_LEVEL)
  62. console = logging.StreamHandler()
  63. console.setLevel(logging.DEBUG)
  64. console.setFormatter(logging.Formatter(log_format))
  65. logger = logging.getLogger(BOT_NAME)
  66. logger.setLevel("DEBUG")
  67. logging.getLogger('').addHandler(console)
  68. discord_logger = logging.getLogger("discord")
  69. discord_logger.handlers.clear()
  70. discord_logger.propagate = True
  71. httpx_logger = logging.getLogger('httpx')
  72. httpx_logger.setLevel(logging.DEBUG)
  73. urllib3.disable_warnings()
  74. update_status_started = False
  75. # Initialiser les intents
  76. intents = discord.Intents.default()
  77. intents.message_content = True # Activer l'intent pour les contenus de message
  78. intents.voice_states = True
  79. class ReplyMode(Enum):
  80. VOICE = "voice"
  81. TEXT = "text"
  82. reply_mode = ReplyMode.VOICE
  83. reply_text_channel = None
  84. class STTSink(Sink):
  85. def __init__(self):
  86. super().__init__()
  87. self.user_ws = {}
  88. self.buffers = {}
  89. self.last_voice = {}
  90. self.flush_tasks = {}
  91. async def _get_ws(self, user_id):
  92. if user_id not in self.user_ws:
  93. ws = await websockets.connect(WHISPER_WS_URL)
  94. self.user_ws[user_id] = ws
  95. asyncio.create_task(self._listen_ws(user_id, ws))
  96. return self.user_ws[user_id]
  97. async def _listen_ws(self, user_id, ws):
  98. try:
  99. async for msg in ws:
  100. data = json.loads(msg)
  101. if data.get("type") == "final":
  102. text = data["text"].strip()
  103. if self._ignore_text(text):
  104. continue # ✅ PAS return
  105. if reply_mode == ReplyMode.TEXT and reply_text_channel:
  106. member = reply_text_channel.guild.get_member(user_id)
  107. name = member.display_name if member else f"User {user_id}"
  108. await reply_text_channel.send(f"🗣️ **{name}** : {text}")
  109. else:
  110. logger.info(f"[STT][{user_id}] {text}")
  111. except Exception as e:
  112. logger.warning(f"[STT][{user_id}] WS fermé : {e}")
  113. def write(self, pcm_bytes: bytes, user_id: int):
  114. if not pcm_bytes:
  115. return
  116. audio = discord_pcm_to_whisper_int16(pcm_bytes)
  117. if not audio:
  118. return
  119. now = time.time()
  120. self.last_voice[user_id] = now
  121. if user_id not in self.buffers:
  122. self.buffers[user_id] = bytearray()
  123. self.buffers[user_id].extend(audio)
  124. buffer_sec = len(self.buffers[user_id]) / (16000 * 2)
  125. if buffer_sec >= 1.0 and user_id not in self.flush_tasks:
  126. self.flush_tasks[user_id] = asyncio.run_coroutine_threadsafe(
  127. self._flush_if_silence(user_id),
  128. MAIN_LOOP
  129. )
  130. async def _flush_if_silence(self, user_id):
  131. await asyncio.sleep(1.2)
  132. if time.time() - self.last_voice.get(user_id, 0) < 0.6:
  133. self.flush_tasks.pop(user_id, None)
  134. return
  135. chunk = bytes(self.buffers.get(user_id, b""))
  136. self.buffers[user_id] = bytearray()
  137. self.flush_tasks.pop(user_id, None)
  138. if len(chunk) < 16000 * 2 * 2:
  139. self.buffers[user_id].extend(chunk)
  140. return
  141. try:
  142. ws = await self._get_ws(user_id)
  143. await ws.send(chunk)
  144. logger.debug(f"[STT] chunk envoyé user={user_id} bytes={len(chunk)}")
  145. except Exception as e:
  146. logger.warning(f"[STT] envoi échoué user={user_id}: {e}")
  147. self.user_ws.pop(user_id, None)
  148. def _ignore_text(self, text: str) -> bool:
  149. BAD = [
  150. "Amara.org"
  151. ]
  152. t = text.lower()
  153. return len(t) < 3 or any(b in t for b in BAD)
  154. # Liste pour stocker l'historique des conversations
  155. conversation_history = []
  156. def discord_pcm_to_whisper_int16(pcm_bytes: bytes) -> bytes:
  157. # PCM 48 kHz int16 -> numpy
  158. audio_48k = np.frombuffer(pcm_bytes, dtype=np.int16)
  159. if audio_48k.size == 0:
  160. return b""
  161. # int16 -> float32 pour resample
  162. audio_float32 = audio_48k.astype(np.float32) / 32768.0
  163. # resample 48kHz -> 16kHz
  164. audio_16k = resample_poly(audio_float32, up=1, down=3)
  165. # float32 -> int16 (CE QUE WHISPER ATTEND)
  166. audio_16k_int16 = np.clip(audio_16k * 32768.0, -32768, 32767).astype(np.int16)
  167. return audio_16k_int16.tobytes()
  168. def filter_message(message):
  169. """Filtre le contenu d'un retour de modèle de language, comme pour enlever les pensées dans le cas par exemple de DeepSeek"""
  170. THOUGHT_TAGS = [
  171. "think",
  172. "analysis",
  173. "response",
  174. "reasoning"
  175. ]
  176. for tag in THOUGHT_TAGS:
  177. message = re.sub(rf"<{tag}>.*?</{tag}>", "", message, flags=re.DOTALL | re.IGNORECASE)
  178. message = re.sub(r"</s>$", "", message)
  179. return message.strip()
  180. def transform_emote(message: str, output: bool) -> str:
  181. """Remplace les smileys par les codes Discord correspondant"""
  182. list_emote = [
  183. (":hap:", "<:hap:355854929073537026>"),
  184. (":angryvault:", "<:angryvault:585550568806940672>"),
  185. (":minou:", "<:minou:358054423462936576>"),
  186. (":cetaitsur:", "<a:cetaitsur:826102032963469324>"),
  187. (":eh:", "<:eh:395979132896280576>"),
  188. (":desu:", "<:desu:388007643077410837>"),
  189. (":bave2:", "<:bave2:412252920558387221>"),
  190. (":haptriste:", "<:haptriste:358054014262181889>"),
  191. (":perplexe:", "<:perplexe:358054891274371082>"),
  192. (":sueur:", "<:sueur:358051940631838721>"),
  193. (":chien:", "<:chien:507606737646518293>"),
  194. (":kemar:", "<:kemar:419607012796792842>"),
  195. (":ouch2:", "<:ouch2:777984650710745138>"),
  196. (":coeur:", "<:coeur:355853389399195649>"),
  197. (":what:", "<:what:587019571207077928>"),
  198. # Un peu un hack mais flemme de renommer la fonction
  199. ("@Rika", "<@1284696824804016138>")
  200. ]
  201. for smiley, discord_code in list_emote:
  202. if output:
  203. message = message.replace(smiley, discord_code)
  204. else:
  205. message = message.replace(discord_code, smiley)
  206. return message
  207. def split_message(message, max_length=2000):
  208. """Divise un message en plusieurs segments de longueur maximale spécifiée."""
  209. if len(message) <= max_length:
  210. return [message]
  211. parts = []
  212. current_part = ""
  213. for line in message.split('\n'):
  214. if len(current_part) + len(line) + 1 > max_length:
  215. parts.append(current_part)
  216. current_part = line + '\n'
  217. else:
  218. current_part += line + '\n'
  219. if current_part:
  220. parts.append(current_part)
  221. return parts
  222. def load_conversation_history():
  223. global conversation_history
  224. if os.path.isfile(CONVERSATION_HISTORY_FILE):
  225. try:
  226. with open(CONVERSATION_HISTORY_FILE, 'r', encoding='utf-8') as f:
  227. loaded_history = json.load(f)
  228. # Exclure uniquement le PERSONALITY_PROMPT
  229. conversation_history = [
  230. msg for msg in loaded_history
  231. if not (msg.get("role") == "system" and msg.get("content") == PERSONALITY_PROMPT)
  232. ]
  233. logger.info(f"Historique chargé depuis {CONVERSATION_HISTORY_FILE}")
  234. except Exception as e:
  235. logger.error(f"Erreur lors du chargement de l'historique : {e}")
  236. conversation_history = []
  237. else:
  238. logger.info(f"Aucun fichier d'historique trouvé. Un nouveau fichier sera créé à {CONVERSATION_HISTORY_FILE}")
  239. def has_text(text):
  240. """Détermine si le texte fourni est non vide après suppression des espaces."""
  241. return bool(text.strip())
  242. def resize_image(image_bytes, attachment_filename=None):
  243. """Redimensionne l'image selon le mode spécifié."""
  244. with Image.open(BytesIO(image_bytes)) as img:
  245. original_format = img.format # Stocker le format original
  246. img.thumbnail((2000, 2000))
  247. buffer = BytesIO()
  248. img_format = img.format or _infer_image_format(attachment_filename)
  249. img.save(buffer, format=img_format)
  250. return buffer.getvalue()
  251. async def encode_image_from_attachment(attachment):
  252. """Encode une image depuis une pièce jointe en base64 après redimensionnement."""
  253. image_data = await attachment.read()
  254. resized_image = resize_image(image_data, attachment_filename=attachment.filename)
  255. return base64.b64encode(resized_image).decode('utf-8')
  256. def _infer_image_format(filename):
  257. """Déduit le format de l'image basé sur l'extension du fichier."""
  258. if filename:
  259. _, ext = os.path.splitext(filename)
  260. ext = ext.lower()
  261. format_mapping = {
  262. '.jpg': 'JPEG',
  263. '.jpeg': 'JPEG',
  264. '.png': 'PNG',
  265. '.gif': 'GIF',
  266. '.bmp': 'BMP',
  267. '.tiff': 'TIFF'
  268. }
  269. return format_mapping.get(ext, 'PNG')
  270. return 'PNG'
  271. # Fonction de sauvegarde de l'historique
  272. def save_conversation_history():
  273. try:
  274. with open(CONVERSATION_HISTORY_FILE, 'w', encoding='utf-8') as f:
  275. json.dump(conversation_history, f, ensure_ascii=False, indent=4)
  276. except Exception as e:
  277. logger.error(f"Erreur lors de la sauvegarde de l'historique : {e}")
  278. # Convertir l'ID du channel en entier
  279. try:
  280. discord_channel_id = int(DISCORD_CHANNEL_ID)
  281. except ValueError:
  282. raise ValueError("L'ID du channel Discord est invalide. Assurez-vous qu'il s'agit d'un entier.")
  283. # Initialiser le client Discord avec les intents modifiés
  284. client_discord = discord.Bot(intents=intents)
  285. MAIN_LOOP = asyncio.get_event_loop()
  286. # Appeler la fonction pour charger l'historique au démarrage
  287. load_conversation_history()
  288. async def call_for_image_analysis(image_data, user_name, user_text=None):
  289. """Appelle l'API pour analyser une image."""
  290. user_content = (
  291. f"{user_name} a envoyé une image avec le messsage : {transform_emote(user_text, False)}."
  292. if user_text
  293. else f"{user_name} a envoyé une image."
  294. )
  295. message_history = {
  296. "role": "user",
  297. "content": [{ "type": "text", "text": user_content }]
  298. }
  299. prompt = {
  300. "role": "system",
  301. "content": PERSONALITY_PROMPT
  302. }
  303. message_to_send = {
  304. "role": "user",
  305. "content": [
  306. {
  307. "type": "text",
  308. "text": user_content
  309. },
  310. {
  311. "type": "image_url",
  312. "image_url": {
  313. "url": f"data:image/jpeg;base64,{image_data}"
  314. }
  315. }
  316. ]
  317. }
  318. if HISTORY_ANALYSIS_IMAGE != "false":
  319. messages = [prompt] + conversation_history + [message_to_send]
  320. else:
  321. messages = [prompt] + [message_to_send]
  322. await add_to_conversation_history(message_history)
  323. analysis = await openai_client.chat.completions.create(
  324. model=MODEL,
  325. stream=False,
  326. messages=messages,
  327. temperature=TEMPERATURE
  328. )
  329. if not analysis or not getattr(analysis, "choices", None):
  330. raise RuntimeError("Réponse API invalide ou vide")
  331. else:
  332. logger.info(f"Analyse de l'image par l'API : {analysis.choices[0].message.content}")
  333. await add_to_conversation_history({
  334. "role": "assistant",
  335. "content": analysis.choices[0].message.content
  336. })
  337. return analysis.choices[0].message.content
  338. async def read_text_file(attachment):
  339. """Lit le contenu d'un fichier texte attaché."""
  340. file_bytes = await attachment.read()
  341. result = from_bytes(file_bytes).best()
  342. if result is None:
  343. raise ValueError("Impossible de détecter l'encodage du fichier")
  344. return str(result)
  345. async def call_openai_api(user_text, user_name):
  346. # Préparer le contenu pour l'appel API
  347. message_to_send = {
  348. "role": "user",
  349. "content": [
  350. {
  351. "type": "text",
  352. "text": f"{user_name} dit : {transform_emote(user_text, False)}"
  353. }
  354. ]
  355. }
  356. # Assembler les messages avec le prompt de personnalité en premier
  357. messages = [
  358. {"role": "system", "content": PERSONALITY_PROMPT}
  359. ] + conversation_history + [message_to_send]
  360. try:
  361. response = await openai_client.chat.completions.create(
  362. model=MODEL,
  363. stream=False,
  364. messages=messages,
  365. temperature=TEMPERATURE
  366. )
  367. await add_to_conversation_history(message_to_send)
  368. # Ajouter la réponse de l'IA directement à l'historique
  369. await add_to_conversation_history({
  370. "role": "assistant",
  371. "content": response.choices[0].message.content
  372. })
  373. return response
  374. except Exception as e:
  375. logger.error(f"Erreur durant l'appel de l'API : {e}")
  376. return None
  377. async def call_openai_api_system(system_text):
  378. # Préparer le contenu pour l'appel API
  379. message = {
  380. "role": "system",
  381. "content": PERSONALITY_PROMPT + f"\n\nPour cette réponse uniquement : {system_text}"
  382. }
  383. response = await openai_client.chat.completions.create(
  384. model=MODEL,
  385. stream=False,
  386. messages=[message],
  387. temperature=1.2
  388. )
  389. return response
  390. @client_discord.event
  391. async def on_ready():
  392. global update_status_started
  393. logger.info(f'{BOT_NAME} connecté en tant que {client_discord.user}')
  394. logger.info(f'Utilisation du modèle {MODEL}')
  395. if not update_status_started:
  396. update_status.start()
  397. update_status_started = True
  398. if not conversation_history:
  399. logger.info("Aucun historique trouvé. L'historique commence vide.")
  400. # Envoyer un message de version dans le canal Discord
  401. channel = client_discord.get_channel(discord_channel_id)
  402. if channel:
  403. if BOOT_MESSAGE != "false":
  404. try:
  405. embed = discord.Embed(
  406. title=f"Bot Démarré",
  407. description=f"🎉 {BOT_NAME} est en ligne ! Version {BOT_VERSION}\nUtilisation du modèle: **{MODEL}**",
  408. color=0x2222aa # Bleu
  409. )
  410. await channel.send(embed=embed)
  411. logger.info(f"Message de connexion envoyé dans le canal ID {discord_channel_id}")
  412. except discord.Forbidden:
  413. logger.error(f"Permissions insuffisantes pour envoyer des messages dans le canal ID {discord_channel_id}.")
  414. except discord.HTTPException as e:
  415. logger.error(f"Erreur lors de l'envoi du message de connexion : {e}")
  416. else:
  417. logger.error(f"Canal avec ID {discord_channel_id} non trouvé.")
  418. @client_discord.event
  419. async def on_message(message):
  420. global conversation_history
  421. # Vérifier si le message provient du canal autorisé
  422. if message.channel.id != discord_channel_id:
  423. return
  424. # Ignorer les messages du bot lui-même
  425. if message.author == client_discord.user:
  426. return
  427. user_text = message.content.strip()
  428. file_content = None
  429. attachment_filename = None
  430. # Vérifier si le message est la commande de réinitialisation
  431. if user_text.lower() == "!reset_history":
  432. # Vérifier si l'utilisateur a les permissions administratives
  433. if not message.author.guild_permissions.administrator:
  434. await message.channel.send("❌ Vous n'avez pas la permission d'utiliser cette commande.")
  435. return
  436. conversation_history = []
  437. save_conversation_history()
  438. await message.channel.send("✅ L'historique des conversations a été réinitialisé.")
  439. logger.info(f"Historique des conversations réinitialisé par {message.author}.")
  440. return # Arrêter le traitement du message après la réinitialisation
  441. # Variable pour stocker si le message contient un fichier
  442. has_file = False
  443. image_data = None
  444. # Vérifier s'il y a une pièce jointe
  445. if message.attachments:
  446. for attachment in message.attachments:
  447. # Vérifier si c'est un fichier avec une extension autorisée
  448. if attachment.content_type and attachment.content_type.startswith('image/'):
  449. try:
  450. image_data = await encode_image_from_attachment(attachment)
  451. break
  452. except Exception as e:
  453. await message.channel.send("Il semble qu'il y ai un souci avec ton image, je ne peux pas l'ouvrir.")
  454. logger.error(f"Erreur lors de la conversion de l'image : {e}")
  455. else:
  456. try:
  457. file_content = await read_text_file(attachment)
  458. attachment_filename = attachment.filename
  459. break
  460. except Exception as e:
  461. await message.channel.send("Désolé, je n'ai pas pu lire ton fichier.")
  462. logger.error(f"Erreur lors de la lecture d'une pièce jointe : {e}")
  463. # Traitement des images
  464. if image_data:
  465. logger.debug(image_data)
  466. has_user_text = has_text(user_text)
  467. user_text_to_use = user_text if has_user_text else None
  468. temp_msg = await message.channel.send(f"*{BOT_NAME} observe l'image...*")
  469. try:
  470. # Analyser l'image avec l'API
  471. analysis = await call_for_image_analysis(image_data, message.author.name, user_text=user_text_to_use)
  472. reply = filter_message(analysis)
  473. reply = transform_emote(analysis, True)
  474. if analysis:
  475. await temp_msg.delete()
  476. await message.channel.send(reply)
  477. if has_user_text:
  478. user_message_text = f"{message.author.name} a envoyé une image avec le messsage : \"{user_text}\"."
  479. else:
  480. user_message_text = f"{message.author.name} a envoyé une image."
  481. user_message = {
  482. "role": "user",
  483. "content": f"{user_message_text}"
  484. }
  485. assistant_message = {
  486. "role": "assistant",
  487. "content": analysis
  488. }
  489. await add_to_conversation_history(user_message)
  490. await add_to_conversation_history(assistant_message)
  491. else:
  492. await temp_msg.delete()
  493. await message.channel.send("Désolé, je n'ai pas pu analyser l'image.")
  494. except Exception as e:
  495. await temp_msg.delete()
  496. await message.channel.send("Une erreur est survenue lors du traitement de l'image.")
  497. logger.error(f"Erreur lors du traitement de l'image: {e}")
  498. return # Ne pas continuer le traitement après une image
  499. # Ajouter le contenu du fichier à la requête si présent
  500. if file_content:
  501. user_text += f"\nPièce jointe ajoutée au message, contenu du fichier '{attachment.filename}':\n{file_content}"
  502. # Vérifier si le texte n'est pas vide après ajout du contenu du fichier
  503. if not has_text(user_text):
  504. return # Ne pas appeler l'API si le texte est vide
  505. async with message.channel.typing():
  506. try:
  507. # Appeler l'API
  508. result = await call_openai_api(user_text, message.author.name)
  509. if result:
  510. reply = result.choices[0].message.content
  511. reply = filter_message(reply)
  512. reply = transform_emote(reply, True)
  513. message_parts = split_message(reply)
  514. for part in message_parts:
  515. await message.channel.send(part)
  516. # Afficher dans la console
  517. logging.info(f"Réponse envoyée. ({len(message_parts)} message(s))")
  518. except Exception as e:
  519. await message.channel.send("Franchement, je sais pas quoi te répondre. <:haptriste:358054014262181889>")
  520. logger.error(f"Erreur lors du traitement du texte: {e}")
  521. async def add_to_conversation_history(new_message):
  522. global conversation_history
  523. conversation_history.append(new_message)
  524. save_conversation_history()
  525. logger.debug(f"Message ajouté à l'historique. Taille actuelle : {len(conversation_history)}")
  526. if len(conversation_history) > CONVERSATION_HISTORY_SIZE:
  527. logger.info(f"Limite de {CONVERSATION_HISTORY_SIZE} messages atteinte.")
  528. excess_messages = len(conversation_history) - CONVERSATION_HISTORY_SIZE
  529. # Supprimer les messages les plus anciens
  530. del conversation_history[:excess_messages]
  531. save_conversation_history()
  532. logger.info(f"{excess_messages} messages les plus anciens ont été supprimés.")
  533. @client_discord.slash_command(name="join", description="Le bot rejoint le vocal (réponse voix ou texte)")
  534. async def join(ctx: discord.ApplicationContext, mode: str = discord.Option(str, choices=["voice", "texte"], default="voice", description="Mode de réponse du bot")):
  535. global reply_mode, reply_text_channel
  536. if not ctx.author.voice:
  537. await ctx.respond("❌ Tu n'es pas dans un salon vocal.", ephemeral=True)
  538. return
  539. channel = ctx.author.voice.channel
  540. if ctx.guild.voice_client:
  541. vc = ctx.guild.voice_client
  542. await vc.move_to(channel)
  543. else:
  544. vc = await channel.connect()
  545. # Démarrer l'écoute audio
  546. vc.start_recording(STTSink(), lambda sink: None)
  547. if mode == "texte":
  548. reply_mode = ReplyMode.TEXT
  549. reply_text_channel = ctx.channel
  550. await ctx.respond("🎧 Connecté au vocal — réponses **en texte** ici.")
  551. else:
  552. reply_mode = ReplyMode.VOICE
  553. reply_text_channel = None
  554. await ctx.respond("🎧 Connecté au vocal — réponses **en voix**.")
  555. @client_discord.slash_command(name="quit", description="Le bot quitte le salon vocal")
  556. async def quit(ctx: discord.ApplicationContext):
  557. vc = ctx.guild.voice_client
  558. if not vc:
  559. await ctx.respond("❌ Je ne suis pas dans un salon vocal.", ephemeral=True)
  560. return
  561. await vc.disconnect()
  562. await ctx.respond("👋 Déconnecté du salon vocal.")
  563. @tasks.loop(minutes=DELAY_TASK_UPDATE_STATUS)
  564. async def update_status():
  565. try:
  566. reply = await call_openai_api_system(PROMPT_STATUS_CHANGE)
  567. status = reply.choices[0].message.content
  568. logger.info(f"Nouveau status Discord: '{status}'")
  569. await client_discord.change_presence(activity=discord.CustomActivity(name=status))
  570. except Exception as e:
  571. logger.warning(f"Impossible de changer le status Discord : {e}")
  572. @update_status.before_loop
  573. async def before_update_status():
  574. await client_discord.wait_until_ready()
  575. logger.info("Démarrage de la tâche update_status")
  576. async def exit_app(signum=None):
  577. globals.logger.debug("Received signal {}".format(signum))
  578. globals.logger.info("Closing all tasks...")
  579. # On ferme la connexion à Discord
  580. if client_discord:
  581. await client_discord.close()
  582. globals.logger.critical("Script halted.")
  583. def handle_signal(signum, frame):
  584. loop = asyncio.get_event_loop()
  585. loop.create_task(exit_app(signum))
  586. # Starting main function
  587. if __name__ == "__main__":
  588. # Catch SIGINT signal (Ctrl-C)
  589. signal.signal(signal.SIGINT, handle_signal)
  590. signal.signal(signal.SIGTERM, handle_signal)
  591. try:
  592. # Démarrer le bot Discord
  593. client_discord.run(DISCORD_TOKEN)
  594. except Exception as e:
  595. globals.logger.error("Encountered exception while running the bot: {}".format(e))