1
0

attachments.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. """Pièces jointes des messages : reconnaissance, vérification et téléchargement."""
  2. from __future__ import annotations
  3. import asyncio
  4. import logging
  5. import aiohttp
  6. import discord
  7. from .config import Settings
  8. from .errors import UserFacingError
  9. from .history import images_marker
  10. from .images import ImageError, prepare_image
  11. from .media import ImageData
  12. from .pdf_files import PDF_EXTENSIONS, PDF_MAX_BYTES, PdfError, prepare_pdf
  13. from .text_files import TEXT_EXTENSIONS, TEXT_FILE_MAX_BYTES, TextFileError, decode_text_file
  14. logger = logging.getLogger("chatbot.discord") # même nom qu'avant : le journal ne change pas
  15. IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff")
  16. # Images lues par message : chacune coûte jusqu'à ~1 600 tokens.
  17. MAX_IMAGES = 4
  18. # Images décodées à la fois, tous messages confondus : chacune peut occuper jusqu'à 400 Mo
  19. # le temps d'être réduite, pour une fraction de seconde.
  20. MAX_IMAGES_AT_ONCE = 1
  21. # Délai d'un téléchargement depuis Discord, en secondes (sinon c'est celui d'aiohttp : 5 minutes).
  22. DOWNLOAD_TIMEOUT = 30
  23. class AttachmentRefused(UserFacingError):
  24. """Pièces jointes refusées avant téléchargement (type non pris en charge, trop nombreuses…)."""
  25. async def fetch(attachment: discord.Attachment, kind: str, error: type[UserFacingError]) -> bytes:
  26. """Télécharge une pièce jointe (`kind` : « image », « PDF », « fichier »). En cas d'échec
  27. (erreur HTTP, coupure réseau, délai dépassé), lève `error` qui demande de réessayer."""
  28. try:
  29. return await asyncio.wait_for(attachment.read(), DOWNLOAD_TIMEOUT)
  30. except (discord.HTTPException, aiohttp.ClientError, asyncio.TimeoutError) as exc:
  31. logger.error("Téléchargement impossible (%s « %s ») : %r", kind, attachment.filename, exc)
  32. raise error(f"Je n'ai pas réussi à télécharger ton {kind}, réessaie.")
  33. def looks_like_image(attachment: discord.Attachment) -> bool:
  34. return (attachment.content_type or "").startswith("image/") or attachment.filename.lower().endswith(IMAGE_EXTENSIONS)
  35. def looks_like_text(attachment: discord.Attachment) -> bool:
  36. return attachment.filename.lower().endswith(TEXT_EXTENSIONS)
  37. def looks_like_pdf(attachment: discord.Attachment) -> bool:
  38. return attachment.content_type == "application/pdf" or attachment.filename.lower().endswith(PDF_EXTENSIONS)
  39. def text_file_prompt(name: str, content: str) -> str:
  40. """Contenu d'un fichier .txt tel qu'il est transmis à Claude."""
  41. return f"\n\n[Contenu du fichier joint « {name} » :]\n{content}\n[Fin du fichier « {name} »]"
  42. def text_file_marker(name: str) -> str:
  43. """Remplace un fichier texte dans la mémoire (son contenu n'est pas conservé)."""
  44. return f"[fichier texte joint « {name} » : tu l'as lu à ce moment-là, mais son contenu n'est plus conservé en mémoire]"
  45. def attachments_marker(attachments: list[ImageData]) -> str:
  46. """Remplace les images ou le PDF d'un message dans la mémoire (leur contenu n'est pas conservé)."""
  47. if attachments[0].is_pdf:
  48. return f"[PDF joint « {attachments[0].name} » : tu l'as lu à ce moment-là, mais son contenu n'est plus conservé en mémoire]"
  49. return images_marker(len(attachments))
  50. class AttachmentReader:
  51. """Télécharge et prépare les pièces jointes, selon les limites du .env (taille des images, pages des PDF)."""
  52. def __init__(self, settings: Settings):
  53. self.settings = settings
  54. self._image_slots = asyncio.Semaphore(MAX_IMAGES_AT_ONCE)
  55. async def read(
  56. self, attachments: list[discord.Attachment]
  57. ) -> tuple[list[ImageData], tuple[str, str] | None]:
  58. """Vérifie les pièces jointes du salon dédié, puis télécharge les images (ou le PDF) et le fichier texte.
  59. Renvoie (images ou PDF, éventuellement aucun ; (nom, contenu) du fichier texte ou None).
  60. Lève AttachmentRefused, ImageError, PdfError ou TextFileError si l'envoi est refusé.
  61. """
  62. if not attachments:
  63. return [], None
  64. images = [a for a in attachments if looks_like_image(a)]
  65. pdfs = [a for a in attachments if looks_like_pdf(a)] if self.settings.pdf_max_pages else []
  66. texts = [a for a in attachments if looks_like_text(a)]
  67. others = [a.filename for a in attachments if a not in images + pdfs + texts]
  68. if others:
  69. kinds = "des images, des PDF" if self.settings.pdf_max_pages else "des images"
  70. raise AttachmentRefused(f"Je ne peux lire que {kinds} et des fichiers texte (.txt, .md, .csv, code…). "
  71. f"Fichier(s) non pris en charge : {', '.join(others)}.")
  72. if len(images) > MAX_IMAGES:
  73. raise AttachmentRefused(f"{MAX_IMAGES} images par message au maximum, s'il te plaît.")
  74. if pdfs and (images or len(pdfs) > 1):
  75. raise AttachmentRefused("Un seul PDF par message, sans image, s'il te plaît.")
  76. if len(texts) > 1:
  77. raise AttachmentRefused("Un seul fichier texte par message, s'il te plaît.")
  78. if images:
  79. files = await self.download_images(images)
  80. else:
  81. files = [await self.download_pdf(pdfs[0])] if pdfs else []
  82. text_file = await self.download_text_file(texts[0]) if texts else None
  83. return files, text_file
  84. async def download_text_file(self, attachment: discord.Attachment) -> tuple[str, str]:
  85. """Télécharge un fichier texte et renvoie (nom, contenu) (lève TextFileError en cas de problème)."""
  86. if attachment.size > TEXT_FILE_MAX_BYTES:
  87. raise TextFileError(f"Ce fichier texte est trop long (maximum {TEXT_FILE_MAX_BYTES // 1000} Ko).")
  88. data = await fetch(attachment, "fichier", TextFileError)
  89. return attachment.filename, decode_text_file(data)
  90. async def download_pdf(self, attachment: discord.Attachment) -> ImageData:
  91. """Télécharge et vérifie un PDF (lève PdfError en cas de problème)."""
  92. if attachment.size > PDF_MAX_BYTES:
  93. raise PdfError(f"Ce PDF est trop lourd (maximum {PDF_MAX_BYTES // 1_000_000} Mo).")
  94. data = await fetch(attachment, "PDF", PdfError)
  95. # Lecture du PDF dans un thread : ne bloque pas le bot.
  96. return await asyncio.to_thread(prepare_pdf, attachment.filename, data, self.settings.pdf_max_pages)
  97. async def download_image(self, attachment: discord.Attachment) -> ImageData:
  98. """Télécharge et prépare une image (lève ImageError en cas de problème)."""
  99. if attachment.size > self.settings.max_image_size:
  100. max_mb = self.settings.max_image_size / 1_000_000
  101. raise ImageError(f"Cette image est trop lourde (maximum {max_mb:.2f} Mo).")
  102. data = await fetch(attachment, "image", ImageError)
  103. # Réduit / convertit l'image si besoin (dans un thread : ne bloque pas le bot),
  104. # une à la fois pour borner la mémoire. Les téléchargements, eux, restent en parallèle.
  105. await self._image_slots.acquire()
  106. preparing = asyncio.ensure_future(asyncio.to_thread(prepare_image, data))
  107. # Place rendue à la fin du thread, pas de l'attente : si l'attente est annulée,
  108. # le thread, lui, continue de décoder.
  109. preparing.add_done_callback(self._release_image_slot)
  110. return await asyncio.shield(preparing)
  111. def _release_image_slot(self, preparing: asyncio.Future) -> None:
  112. self._image_slots.release()
  113. if not preparing.cancelled():
  114. preparing.exception() # lue : après une annulation, personne d'autre ne la lira
  115. async def download_images(self, attachments: list[discord.Attachment]) -> list[ImageData]:
  116. """Télécharge plusieurs images en parallèle et les prépare (lève ImageError si l'une pose problème).
  117. Si plusieurs échouent, l'erreur nomme la première dans l'ordre du message, pas la plus rapide.
  118. """
  119. results = await asyncio.gather(*(self.download_image(a) for a in attachments), return_exceptions=True)
  120. for attachment, result in zip(attachments, results):
  121. if isinstance(result, ImageError) and len(attachments) > 1:
  122. # précise laquelle, sinon « cette image » est ambigu
  123. raise ImageError(f"« {attachment.filename} » : {result.user_message}")
  124. if isinstance(result, BaseException):
  125. raise result
  126. return list(results)
  127. async def quoted_images(self, attachments: list[discord.Attachment], kind: str) -> list[ImageData]:
  128. """Télécharge les MAX_IMAGES premières images d'un message cité ou transféré (les illisibles sont ignorées)."""
  129. images = [a for a in attachments if looks_like_image(a)][:MAX_IMAGES]
  130. results = await asyncio.gather(*(self.download_image(a) for a in images), return_exceptions=True)
  131. for result in results:
  132. if isinstance(result, ImageError):
  133. logger.warning("Image du message %s ignorée : %s", kind, result.user_message)
  134. elif isinstance(result, BaseException):
  135. raise result
  136. return [r for r in results if isinstance(r, ImageData)]