attachments.py 8.0 KB

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