Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/api/controllers/chats_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")):
Expand Down
4 changes: 2 additions & 2 deletions src/api/controllers/files_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)):
Expand Down
2 changes: 0 additions & 2 deletions src/api/extensions/chats_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/api/extensions/files_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion src/api/responses/file_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<video>` plays it all the same and a navigation downloads it."""
headers = {**cls._download_headers(content.file_name), "Accept-Ranges": "bytes"}
if content.content_length is not None:
headers["Content-Length"] = str(content.content_length)
if content.content_range:
headers["Content-Range"] = content.content_range
return StreamingResponse(content.chunks, status_code=content.status_code, media_type=content.content_type, headers=headers)

@classmethod
def image(cls, content: BinaryContent) -> Response:
Expand Down
5 changes: 4 additions & 1 deletion src/application/abstractions/i_chat_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ class IChatReader(ABC):
"""Graph chat messages made ready for the web."""

@abstractmethod
def enrich(self, note: IGraphClient, broker: IAccountBroker, messages: list[dict]) -> list[dict]: ...
def enrich(self, note: IGraphClient, broker: IAccountBroker, messages: list[dict], chat_id: str | None = None) -> list[dict]: ...

@abstractmethod
def file_ref(self, note: IGraphClient, broker: IAccountBroker, chat_id: str, message_id: str, attachment_id: str) -> dict | None: ...
3 changes: 3 additions & 0 deletions src/application/abstractions/i_file_thumbnails.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ def image_type(self, head: bytes) -> str | None: ...
@abstractmethod
def safe_graph_id(self, value) -> bool: ...

@abstractmethod
def remember_item(self, broker: IAccountBroker, url: str, item: dict) -> None: ...

@abstractmethod
def chat_file_thumbnail(self, note: IGraphClient, broker: IAccountBroker, url: str, size: str) -> bytes | None: ...

Expand Down
2 changes: 1 addition & 1 deletion src/application/abstractions/i_graph_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class IGraphClient(ABC):
"""What the application asks of a per-account Graph client. OutNote is registered as one by the infrastructure."""

@abstractmethod
def get(self, path, params=None, client=None, raw=False): ...
def get(self, path, params=None, client=None, raw=False, retry=True): ...

@abstractmethod
def list(self, path, params=None, limit=None, client=None): ...
Expand Down
6 changes: 4 additions & 2 deletions src/application/abstractions/i_microsoft_http_clients.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from abc import ABC, abstractmethod
from collections.abc import Iterator
from collections.abc import Callable

from src.domain.files.file_stream import FileStream


class IMicrosoftHttpClients(ABC):
Expand All @@ -9,7 +11,7 @@ class IMicrosoftHttpClients(ABC):
def download_client(self): ...

@abstractmethod
def stream_download(self, url: str, limit: int, follow_redirects: bool = True) -> Iterator[bytes]: ...
def open_download(self, url: str, limit: int, allowed: Callable[[str], bool], content_type: str, file_name: str, byte_range: str | None = None) -> FileStream: ...

@abstractmethod
async def post_form(self, url: str, data: dict, client=None) -> dict: ...
Expand Down
9 changes: 9 additions & 0 deletions src/application/abstractions/i_teams_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ def fetch_bytes(self, url: str, cap: int) -> bytes: ...
@abstractmethod
def download_link(self, note: IGraphClient, path: str, item: dict) -> str: ...

@abstractmethod
def cached_download(self, broker: IAccountBroker, key: tuple) -> dict | None: ...

@abstractmethod
def remember_download(self, broker: IAccountBroker, key: tuple, target: dict) -> None: ...

@abstractmethod
def forget_download(self, broker: IAccountBroker, key: tuple) -> None: ...

@abstractmethod
def item_base(self, drive_id: str | None, item_id: str) -> str: ...

Expand Down
8 changes: 4 additions & 4 deletions src/application/queries/get_chat_file_pdf_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
from mediatorx import IQuery, IQueryHandler

from src.application.abstractions.i_account_runner import IAccountRunner
from src.application.abstractions.i_chat_reader import IChatReader
from src.application.abstractions.i_file_thumbnails import IFileThumbnails
from src.application.abstractions.i_pdf_converter import IPdfConverter
from src.application.services.chat_file_lookup import ChatFileLookup
from src.application.services.drive_item_checks import DriveItemChecks
from src.application.services.graph_paths import GraphPaths
from src.domain.errors.service_error import ServiceError
Expand All @@ -22,9 +22,9 @@ class GetChatFilePdfQuery(IQuery[SpooledPdf]):


class GetChatFilePdfHandler(IQueryHandler[GetChatFilePdfQuery, SpooledPdf]):
def __init__(self, accounts: IAccountRunner, lookup: ChatFileLookup, thumbnails: IFileThumbnails, pdf: IPdfConverter, checks: DriveItemChecks):
def __init__(self, accounts: IAccountRunner, reader: IChatReader, thumbnails: IFileThumbnails, pdf: IPdfConverter, checks: DriveItemChecks):
self._accounts = accounts
self._lookup = lookup
self._reader = reader
self._thumbnails = thumbnails
self._pdf = pdf
self._checks = checks
Expand All @@ -35,7 +35,7 @@ async def handle(self, query: GetChatFilePdfQuery) -> SpooledPdf:
GraphIds.check(query.attachment_id, GraphIds.ATTACHMENT_ID_PATTERN, "attachment_id")

def convert(note, broker):
ref = self._lookup.find(note, query.chat_id, query.message_id, query.attachment_id)
ref = self._reader.file_ref(note, broker, query.chat_id, query.message_id, query.attachment_id)
if ref is None:
raise ServiceError(404, "File not found in the message.")
if not self._thumbnails.is_share_url(ref.get("contentUrl")):
Expand Down
38 changes: 19 additions & 19 deletions src/application/queries/get_chat_file_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

from mediatorx import IQuery, IQueryHandler

from src.application.abstractions.i_account_runner import IAccountRunner
from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients
from src.application.abstractions.i_chat_reader import IChatReader
from src.application.abstractions.i_file_thumbnails import IFileThumbnails
from src.application.abstractions.i_teams_files import ITeamsFiles
from src.application.services.chat_file_lookup import ChatFileLookup
from src.application.services.content_names import ContentNames
from src.application.services.drive_item_checks import DriveItemChecks
from src.application.services.file_downloads import FileDownloads
from src.application.services.graph_paths import GraphPaths
from src.domain.errors.service_error import ServiceError
from src.domain.files.file_limits import FileLimits
Expand All @@ -21,34 +20,35 @@ class GetChatFileQuery(IQuery[FileStream]):
chat_id: str
message_id: str
attachment_id: str
byte_range: str | None = None


class GetChatFileHandler(IQueryHandler[GetChatFileQuery, FileStream]):
def __init__(self, accounts: IAccountRunner, lookup: ChatFileLookup, files: ITeamsFiles, http: IMicrosoftHttpClients):
self._accounts = accounts
self._lookup = lookup
def __init__(self, reader: IChatReader, thumbnails: IFileThumbnails, files: ITeamsFiles, downloads: FileDownloads):
self._reader = reader
self._thumbnails = thumbnails
self._files = files
self._http = http
self._downloads = downloads

async def handle(self, query: GetChatFileQuery) -> FileStream:
GraphIds.check(query.chat_id, GraphIds.CHAT_ID_PATTERN, "chat_id")
GraphIds.check(query.message_id, GraphIds.MESSAGE_ID_PATTERN, "message_id")
GraphIds.check(query.attachment_id, GraphIds.ATTACHMENT_ID_PATTERN, "attachment_id")

def resolve(note, broker):
ref = self._lookup.find(note, query.chat_id, query.message_id, query.attachment_id)
def read(note, broker):
ref = self._reader.file_ref(note, broker, query.chat_id, query.message_id, query.attachment_id)
if ref is None:
raise ServiceError(404, "File not found in the message.")
item = note.get(GraphPaths.drive_item_path(ref["contentUrl"], "name,size,file,folder,@microsoft.graph.downloadUrl"))
path = GraphPaths.share_item_path(ref["contentUrl"])
# Throttled, this answers at once: the caller waits, rather than a worker thread.
item = note.get(path, retry=False)
if item.get("folder") is not None:
raise ServiceError(404, DriveItemChecks.FOLDER_DETAIL)
if (item.get("size") or 0) > FileLimits.MAX_CHAT_FILE_BYTES:
raise ServiceError(413, "The file is larger than 100 MB.")
url = self._files.download_link(note, f"shares/{GraphPaths.share_id(ref['contentUrl'])}/driveItem", item)
if not self._files.is_download_url(url):
raise ServiceError(502, "The file has an unexpected download location.")
return item, url

item, url = await self._accounts.run(query.refresh_token, resolve)
# The download URL is pre-authorized and short-lived; the bytes stream through without being held.
return FileStream(self._http.stream_download(url, FileLimits.MAX_CHAT_FILE_BYTES, follow_redirects=False), ContentNames.safe_content_type((item.get("file") or {}).get("mimeType")), ContentNames.safe_name(item.get("name")))
self._thumbnails.remember_item(broker, ref["contentUrl"], item)
return item, self._files.download_link(note, path, item)

key = ("chat", query.chat_id, query.message_id, query.attachment_id)
# The link is pre-authorized and short-lived; the bytes stream through without being held.
return await self._downloads.open(query.refresh_token, key, read, FileLimits.MAX_CHAT_FILE_BYTES, query.byte_range)
8 changes: 4 additions & 4 deletions src/application/queries/get_chat_file_thumbnail_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

from mediatorx import IQuery, IQueryHandler

from src.application.abstractions.i_chat_reader import IChatReader
from src.application.abstractions.i_file_thumbnails import IFileThumbnails
from src.application.services.account_image_reader import AccountImageReader
from src.application.services.chat_file_lookup import ChatFileLookup
from src.domain.files.binary_content import BinaryContent
from src.domain.validation.graph_ids import GraphIds

Expand All @@ -19,9 +19,9 @@ class GetChatFileThumbnailQuery(IQuery[BinaryContent]):


class GetChatFileThumbnailHandler(IQueryHandler[GetChatFileThumbnailQuery, BinaryContent]):
def __init__(self, images: AccountImageReader, lookup: ChatFileLookup, thumbnails: IFileThumbnails):
def __init__(self, images: AccountImageReader, reader: IChatReader, thumbnails: IFileThumbnails):
self._images = images
self._lookup = lookup
self._reader = reader
self._thumbnails = thumbnails

async def handle(self, query: GetChatFileThumbnailQuery) -> BinaryContent:
Expand All @@ -31,7 +31,7 @@ async def handle(self, query: GetChatFileThumbnailQuery) -> BinaryContent:
self._images.check_thumbnail_size(query.size)

def fetch(note, broker):
ref = self._lookup.find(note, query.chat_id, query.message_id, query.attachment_id)
ref = self._reader.file_ref(note, broker, query.chat_id, query.message_id, query.attachment_id)
if ref is None or not self._thumbnails.is_share_url(ref["contentUrl"]):
return None
return self._thumbnails.chat_file_thumbnail(note, broker, ref["contentUrl"], query.size)
Expand Down
Loading
Loading