فهرست منبع

Pièces jointes : une coupure réseau pendant un téléchargement ne fait plus perdre le message

Attachment.read() ne transforme en HTTPException que les réponses HTTP en
erreur : une connexion coupée ou un délai dépassé remontait en
aiohttp.ClientError ou asyncio.TimeoutError, sortait de on_message, et le
message restait sans réponse ni mémoire. Pour les images citées, c'était le
contraire de ce que promettait quoted_images.

Une fonction fetch commune intercepte maintenant ces erreurs et borne chaque
téléchargement à 30 secondes (DOWNLOAD_TIMEOUT) : une pièce jointe du message
donne « réessaie », une image citée illisible est ignorée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 1 روز پیش
والد
کامیت
c02ab9f960
3فایلهای تغییر یافته به همراه55 افزوده شده و 18 حذف شده
  1. 2 0
      CHANGELOG.md
  2. 16 18
      bot/attachments.py
  3. 37 0
      tests/test_attachments_prep.py

+ 2 - 0
CHANGELOG.md

@@ -12,6 +12,8 @@ Les versions les plus récentes sont en haut. Chaque entrée commence par un ré
 
 - **Une image aux dimensions énormes ne fait plus tomber le bot.** Un fichier léger mais de dizaines de milliers de pixels de côté pouvait occuper plus d'1 Go de mémoire une fois décodé, et faire redémarrer le bot. Il est maintenant refusé d'emblée au-delà de 40 millions de pixels (120 millions pour la plupart des photos JPEG), et les images sont préparées une à la fois.
 
+- **Une coupure réseau pendant un téléchargement ne fait plus ignorer le message.** Si une pièce jointe ne se télécharge pas (connexion coupée, ou plus de 30 secondes), Rika demande de réessayer ; une image illisible dans un message cité ou transféré est simplement ignorée. Avant, le message restait sans réponse.
+
 ---
 
 ## Version 5.12

+ 16 - 18
bot/attachments.py

@@ -5,6 +5,7 @@ from __future__ import annotations
 import asyncio
 import logging
 
+import aiohttp
 import discord
 
 from .config import Settings
@@ -23,12 +24,24 @@ MAX_IMAGES = 4
 # Images décodées à la fois, tous messages confondus : chacune peut occuper jusqu'à 400 Mo
 # le temps d'être réduite, pour une fraction de seconde.
 MAX_IMAGES_AT_ONCE = 1
+# Délai d'un téléchargement depuis Discord, en secondes (sinon c'est celui d'aiohttp : 5 minutes).
+DOWNLOAD_TIMEOUT = 30
 
 
 class AttachmentRefused(UserFacingError):
     """Pièces jointes refusées avant téléchargement (type non pris en charge, trop nombreuses…)."""
 
 
+async def fetch(attachment: discord.Attachment, kind: str, error: type[UserFacingError]) -> bytes:
+    """Télécharge une pièce jointe (`kind` : « image », « PDF », « fichier »). En cas d'échec
+    (erreur HTTP, coupure réseau, délai dépassé), lève `error` qui demande de réessayer."""
+    try:
+        return await asyncio.wait_for(attachment.read(), DOWNLOAD_TIMEOUT)
+    except (discord.HTTPException, aiohttp.ClientError, asyncio.TimeoutError) as exc:
+        logger.error("Téléchargement impossible (%s « %s ») : %r", kind, attachment.filename, exc)
+        raise error(f"Je n'ai pas réussi à télécharger ton {kind}, réessaie.")
+
+
 def looks_like_image(attachment: discord.Attachment) -> bool:
     return (attachment.content_type or "").startswith("image/") or attachment.filename.lower().endswith(IMAGE_EXTENSIONS)
 
@@ -103,12 +116,7 @@ class AttachmentReader:
         if attachment.size > TEXT_FILE_MAX_BYTES:
             raise TextFileError(f"Ce fichier texte est trop long (maximum {TEXT_FILE_MAX_BYTES // 1000} Ko).")
 
-        try:
-            data = await attachment.read()
-        except discord.HTTPException as exc:
-            logger.error("Téléchargement du fichier texte impossible : %s", exc)
-            raise TextFileError("Je n'ai pas réussi à télécharger ton fichier, réessaie.")
-
+        data = await fetch(attachment, "fichier", TextFileError)
         return attachment.filename, decode_text_file(data)
 
     async def download_pdf(self, attachment: discord.Attachment) -> ImageData:
@@ -116,12 +124,7 @@ class AttachmentReader:
         if attachment.size > PDF_MAX_BYTES:
             raise PdfError(f"Ce PDF est trop lourd (maximum {PDF_MAX_BYTES // 1_000_000} Mo).")
 
-        try:
-            data = await attachment.read()
-        except discord.HTTPException as exc:
-            logger.error("Téléchargement du PDF impossible : %s", exc)
-            raise PdfError("Je n'ai pas réussi à télécharger ton PDF, réessaie.")
-
+        data = await fetch(attachment, "PDF", PdfError)
         # Lecture du PDF dans un thread : ne bloque pas le bot.
         return await asyncio.to_thread(prepare_pdf, attachment.filename, data, self.settings.pdf_max_pages)
 
@@ -131,12 +134,7 @@ class AttachmentReader:
             max_mb = self.settings.max_image_size / 1_000_000
             raise ImageError(f"Cette image est trop lourde (maximum {max_mb:.2f} Mo).")
 
-        try:
-            data = await attachment.read()
-        except discord.HTTPException as exc:
-            logger.error("Téléchargement de l'image impossible : %s", exc)
-            raise ImageError("Je n'ai pas réussi à télécharger ton image, réessaie.")
-
+        data = await fetch(attachment, "image", ImageError)
         # Réduit / convertit l'image si besoin (dans un thread : ne bloque pas le bot),
         # une à la fois pour borner la mémoire. Les téléchargements, eux, restent en parallèle.
         await self._image_slots.acquire()

+ 37 - 0
tests/test_attachments_prep.py

@@ -12,6 +12,7 @@ import zlib
 from types import SimpleNamespace
 from unittest import mock
 
+import aiohttp
 from PIL import Image
 from pypdf import PdfWriter
 
@@ -218,6 +219,42 @@ class ImagesAtOnceTest(unittest.IsolatedAsyncioTestCase):
         return reader, files
 
 
+class DownloadFailureTest(unittest.IsolatedAsyncioTestCase):
+    """Une coupure réseau ou un délai dépassé pendant le téléchargement prévient le membre au lieu de tout faire échouer."""
+
+    def setUp(self):
+        self.reader = AttachmentReader(SimpleNamespace(max_image_size=10_000_000, pdf_max_pages=10))
+
+    @staticmethod
+    def file(name: str, read) -> SimpleNamespace:
+        return SimpleNamespace(filename=name, content_type=None, size=10, read=read)
+
+    def broken(self, name: str) -> SimpleNamespace:
+        return self.file(name, mock.AsyncMock(side_effect=aiohttp.ClientConnectionError("connexion réinitialisée")))
+
+    async def test_connection_error_asks_to_retry(self):
+        for name, error in (("photo.png", ImageError), ("doc.pdf", PdfError), ("notes.txt", TextFileError)):
+            with self.subTest(name), self.assertLogs("chatbot.discord", "ERROR"):
+                with self.assertRaises(error) as caught:
+                    await self.reader.read([self.broken(name)])
+                self.assertIn("réessaie", caught.exception.user_message)
+
+    async def test_quoted_image_unreadable_is_ignored(self):
+        good = self.file("ok.png", mock.AsyncMock(return_value=b"ok"))
+        with mock.patch.object(attachments, "prepare_image", lambda data: ImageData("image/png", data)), \
+                self.assertLogs("chatbot.discord", "WARNING"):
+            result = await self.reader.quoted_images([self.broken("ko.png"), good], "cité")
+        self.assertEqual([r.data for r in result], [b"ok"])
+
+    async def test_download_timeout(self):
+        async def never_ends():
+            await asyncio.sleep(10)
+
+        with mock.patch.object(attachments, "DOWNLOAD_TIMEOUT", 0.01), self.assertLogs("chatbot.discord", "ERROR"):
+            with self.assertRaises(ImageError):
+                await self.reader.download_image(self.file("lente.png", never_ends))
+
+
 def pdf(pages: int, password: str | None = None, owner_password: str | None = None) -> bytes:
     writer = PdfWriter()
     for _ in range(pages):