diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2c91be5..53c9dbd 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -31,14 +31,16 @@ The Schuly **Microsoft plugin** is the only caller. - `entrance.http_client()` offers only `request`, `post`, `head` and `close`, takes a float timeout and reads every body into memory. Anything that must stay capped or be streamed uses `IMicrosoftHttpClients.download_client()` (httpx, HTTP/1.1, no redirects by default, no cookies kept, - because all accounts share it). Don't call `note.download` or `note.download_shared`: they hold the + because all accounts share it). `open_download` opens a pre-authorized link before the answer starts, so a refusal is a real status; it follows + redirects only through the host check, passes `Range` on and streams raw. `FileDownloads` keeps a link for 10 minutes, so a video's seeks cost no Graph calls. Don't call `note.download` or `note.download_shared`: they hold the whole file with no cap and no host check. - `except entrance.HTTPError` works alone, but it is a tuple: unpack it next to other types. - `TeamsFiles.download_url` stays: it serves the `shares/...` path chat files need, which OutNote's `download_url` can't. A folder is a 404 that says so; Graph's refusal at `/content` keeps its own status (403, 404, 415, 429). - One `OutNote` per account lives on its broker. A throttled call raises `GraphError` 429 after about - 60 s, which `AccountRunner` answers as 429 with `Retry-After` (and any rotated token). + 60 s, or at once with `retry=False`, which `AccountRunner` answers as 429 with `Retry-After` (and any rotated token) and logs as a warning with the masked route (`GraphCallTrail`). Keep ids and content out of that line. The chat file, thumbnail and message reads use `retry=False`: the caller waits, not a worker thread. +- Graph throttles the single chat message read (`chats/{id}/messages/{id}`) far sooner than the list, so `ChatReader.file_ref` answers from what the list already said, then asks `note.chat_message` (Teams' own API, which falls back to that same read and may answer 429 for it), and when that refuses or lacks the file, the list. - `AccountRunner` strips `_native` from every result. No route calls OutNote's native reads today: chat messages stay on Graph's list because `ChatReader` and the callers need its attachments, mentions and reactions. `emails()` gets an explicit `$select` because the web's list shows `toRecipients`. diff --git a/requirements.txt b/requirements.txt index f06f40c..7622dae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ fastapi==0.122.0 uvicorn==0.38.0 httpx==0.28.1 python-multipart==0.0.20 -outnote==2.0.1 +outnote==2.2.0 ms-entrance==2.0.0 wreq==0.13.0 python-docx==1.2.0 diff --git a/src/api/controllers/chats_controller.py b/src/api/controllers/chats_controller.py index dec6b94..ad3a1b2 100644 --- a/src/api/controllers/chats_controller.py +++ b/src/api/controllers/chats_controller.py @@ -39,8 +39,8 @@ async def chat_hosted_content(self, chat_id: str, message_id: str, hosted_id: st return FileResponses.image(await self.mediator.send(GetChatHostedContentQuery(token, chat_id, message_id, hosted_id))) @router.get("/chats/{chat_id}/messages/{message_id}/files/{attachment_id}", tags=["Microsoft 365"], response_class=StreamingResponse, responses={200: {"content": {"application/octet-stream": {}}}}) - async def chat_file(self, chat_id: str, message_id: str, attachment_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): - return FileResponses.stream(await self.mediator.send(GetChatFileQuery(token, chat_id, message_id, attachment_id))) + async def chat_file(self, chat_id: str, message_id: str, attachment_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), byte_range: str | None = Header(None, alias="Range")): + return FileResponses.stream(await self.mediator.send(GetChatFileQuery(token, chat_id, message_id, attachment_id, byte_range))) @router.get("/chats/{chat_id}/messages/{message_id}/files/{attachment_id}/thumbnail", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) async def chat_file_thumbnail(self, chat_id: str, message_id: str, attachment_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), size: str = Query("medium")): diff --git a/src/api/controllers/files_controller.py b/src/api/controllers/files_controller.py index 8c04d1e..7b74839 100644 --- a/src/api/controllers/files_controller.py +++ b/src/api/controllers/files_controller.py @@ -78,9 +78,9 @@ async def drive_item_children(self, drive_id: str, item_id: str, response: Respo return await self.mediator.send(ListDriveItemChildrenQuery(token, drive_id, item_id, top)) @router.get("/drives/{drive_id}/items/{item_id}/content", tags=["Teams files"], response_class=StreamingResponse, responses={200: {"content": {"application/octet-stream": {}}}}) - async def drive_item_content(self, drive_id: str, item_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + async def drive_item_content(self, drive_id: str, item_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), byte_range: str | None = Header(None, alias="Range")): """The file's bytes as an attachment (<= 100 MB, no-store).""" - return FileResponses.stream(await self.mediator.send(GetDriveItemContentQuery(token, drive_id, item_id))) + return FileResponses.stream(await self.mediator.send(GetDriveItemContentQuery(token, drive_id, item_id, byte_range))) @router.get("/drives/{drive_id}/items/{item_id}/pdf", tags=["Files as PDF"], response_class=StreamingResponse, responses={200: {"content": {"application/pdf": {}}}}) async def shared_drive_item_pdf(self, drive_id: str, item_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): diff --git a/src/api/extensions/chats_extensions.py b/src/api/extensions/chats_extensions.py index 94e7280..ca8610e 100644 --- a/src/api/extensions/chats_extensions.py +++ b/src/api/extensions/chats_extensions.py @@ -12,7 +12,6 @@ from src.application.queries.get_chat_photo_query import GetChatPhotoQuery, GetChatPhotoHandler from src.application.queries.list_chat_messages_query import ListChatMessagesQuery, ListChatMessagesHandler from src.application.queries.list_chats_query import ListChatsQuery, ListChatsHandler -from src.application.services.chat_file_lookup import ChatFileLookup from src.application.services.chat_html_sanitizer import ChatHtmlSanitizer from src.infrastructure.teams.chat_reader import ChatReader from src.infrastructure.teams.chat_sender import ChatSender @@ -23,7 +22,6 @@ class ChatsExtensions: @staticmethod def add_chats(services: ServiceCollection) -> ServiceCollection: services.add_singleton(ChatHtmlSanitizer) - services.add_singleton(ChatFileLookup) services.add_singleton(ITeamsChat, TeamsChatClient) services.add_singleton(IChatReader, ChatReader) services.add_singleton(IChatSender, ChatSender) diff --git a/src/api/extensions/files_extensions.py b/src/api/extensions/files_extensions.py index 9b2c3cf..8fa4fbb 100644 --- a/src/api/extensions/files_extensions.py +++ b/src/api/extensions/files_extensions.py @@ -21,6 +21,7 @@ from src.application.services.drive_file_text_reader import DriveFileTextReader from src.application.services.drive_item_checks import DriveItemChecks from src.infrastructure.files.file_text_extractor import FileTextExtractor +from src.application.services.file_downloads import FileDownloads from src.infrastructure.graph.file_thumbnails import FileThumbnails from src.infrastructure.graph.pdf_converter import PdfConverter from src.infrastructure.graph.teams_files import TeamsFiles @@ -34,6 +35,7 @@ def add_files(services: ServiceCollection) -> ServiceCollection: services.add_singleton(IFileTextExtractor, FileTextExtractor) services.add_singleton(ITeamsFiles, TeamsFiles) services.add_singleton(DriveItemChecks) + services.add_singleton(FileDownloads) services.add_singleton(DriveFileTextReader) services.add_singleton(AccountImageReader) diff --git a/src/api/responses/file_responses.py b/src/api/responses/file_responses.py index 3b2c4d0..609046c 100644 --- a/src/api/responses/file_responses.py +++ b/src/api/responses/file_responses.py @@ -21,7 +21,13 @@ def attachment(cls, content: BinaryContent) -> Response: @classmethod def stream(cls, content: FileStream) -> StreamingResponse: - return StreamingResponse(content.chunks, media_type=content.content_type, headers=cls._download_headers(content.file_name)) + """Always an attachment that can't run, whatever the type: a `