Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
16 changes: 16 additions & 0 deletions autogpt_platform/backend/backend/copilot/bot/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ class FileAttachment(BaseModel):
content: bytes


class InboundAttachment(BaseModel):
"""A file the user attached to an inbound platform message.

The adapter downloads the bytes from the platform up-front (bounded by the
adapter's ``max_attachment_bytes``); the handler then uploads them to the
user's workspace so AutoPilot can read them during the turn.
"""

filename: str
mime_type: str
content: bytes


class ChannelInfo(BaseModel):
"""A channel the bot can post to, scoped to a server it's connected to.

Expand Down Expand Up @@ -108,6 +121,9 @@ class MessageContext:
# Other threads/channels the message linked or @-referenced, fetched by the
# bot up-front so the model has their content without web-fetching Discord.
referenced_conversations: tuple[ReferencedConversation, ...] = ()
# Files the user attached to this message (bytes already downloaded). The
# handler uploads these to the workspace and passes their IDs to the turn.
attachments: tuple[InboundAttachment, ...] = ()

@property
def is_dm(self) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ChannelInfo,
ChannelType,
FileAttachment,
InboundAttachment,
MessageCallback,
MessageContext,
MessageHistoryEntry,
Expand Down Expand Up @@ -54,6 +55,10 @@
MAX_REFERENCED_CONVERSATIONS = 3
REFERENCED_HISTORY_LIMIT = 200
REFERENCED_CHAR_BUDGET = 8000

# Cap how many attachments we pull off a single message into the workspace, so
# a message with dozens of files can't fan out into that many uploads/scans.
MAX_INBOUND_ATTACHMENTS = 10
Comment thread
ntindle marked this conversation as resolved.
# When a link names a specific message, fetch that message plus a little of the
# conversation leading up to it (rather than the channel's latest activity).
REFERENCED_MESSAGE_CONTEXT = 15
Expand Down Expand Up @@ -457,9 +462,48 @@ async def on_message(message: discord.Message) -> None:
thread_history=thread_history,
mentionable_users=self._collect_mentionable_users(message),
referenced_conversations=referenced,
attachments=await self._extract_attachments(message),
)
await self._on_message_callback(ctx, self)

async def _extract_attachments(
self, message: discord.Message
) -> tuple[InboundAttachment, ...]:
"""Download the user's file attachments so the handler can upload them.

Bounded by count and the adapter's per-file byte cap; oversized files
are skipped (the model still sees the rest of the message). A single
Comment thread
Bentlybro marked this conversation as resolved.
Outdated
failed download is skipped rather than failing the whole turn.
"""
attachments: list[InboundAttachment] = []
if len(message.attachments) > MAX_INBOUND_ATTACHMENTS:
logger.info(
"Ignoring %d attachment(s) beyond the per-message limit of %d",
len(message.attachments) - MAX_INBOUND_ATTACHMENTS,
MAX_INBOUND_ATTACHMENTS,
)
for attachment in message.attachments[:MAX_INBOUND_ATTACHMENTS]:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if attachment.size > self.max_attachment_bytes:
logger.info(
"Skipping oversized attachment %s (%d bytes)",
attachment.filename,
attachment.size,
)
continue
try:
content = await attachment.read()
Comment thread
ntindle marked this conversation as resolved.
Comment thread
ntindle marked this conversation as resolved.
except (discord.HTTPException, discord.NotFound):
logger.warning("Could not download attachment %s", attachment.filename)
continue
attachments.append(
InboundAttachment(
filename=attachment.filename or "file",
mime_type=attachment.content_type or "application/octet-stream",
content=content,
)
)
return tuple(attachments)

async def _refresh_known_server_names(self) -> None:
"""Push current display names for every guild the bot is in."""
for guild in self._client.guilds:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from backend.copilot.bot.adapters.base import FileAttachment
from backend.copilot.bot.adapters.discord.adapter import (
MAX_INBOUND_ATTACHMENTS,
THREAD_HISTORY_CHAR_BUDGET,
THREAD_HISTORY_LIMIT,
DiscordAdapter,
Expand Down Expand Up @@ -1406,3 +1407,79 @@ async def test_skips_channel_with_no_readable_messages(self):
_incoming(111, 555), "https://discord.com/channels/111/222/333"
)
assert result == ()


# ── Attachment extraction ──────────────────────────────────────────────


def _discord_attachment(
filename: str, content_type: str | None, size: int, data: bytes = b"x"
) -> MagicMock:
att = MagicMock()
att.filename = filename
att.content_type = content_type
att.size = size
att.read = AsyncMock(return_value=data)
return att


class TestExtractAttachments:
@pytest.mark.asyncio
async def test_downloads_attachments_with_mime(self):
adapter, _ = _bare_adapter()
msg = MagicMock()
msg.attachments = [_discord_attachment("a.png", "image/png", 10, b"png")]

result = await adapter._extract_attachments(msg)

assert len(result) == 1
assert result[0].filename == "a.png"
assert result[0].mime_type == "image/png"
assert result[0].content == b"png"

@pytest.mark.asyncio
async def test_skips_oversized_attachment(self):
adapter, _ = _bare_adapter()
big = adapter.max_attachment_bytes + 1
msg = MagicMock()
msg.attachments = [_discord_attachment("huge.bin", None, big)]

result = await adapter._extract_attachments(msg)

assert result == ()

@pytest.mark.asyncio
async def test_defaults_missing_mime_to_octet_stream(self):
adapter, _ = _bare_adapter()
msg = MagicMock()
msg.attachments = [_discord_attachment("data", None, 5)]

result = await adapter._extract_attachments(msg)

assert result[0].mime_type == "application/octet-stream"

@pytest.mark.asyncio
async def test_caps_attachment_count(self):
adapter, _ = _bare_adapter()
msg = MagicMock()
msg.attachments = [
_discord_attachment(f"f{i}.txt", "text/plain", 5)
for i in range(MAX_INBOUND_ATTACHMENTS + 3)
]

result = await adapter._extract_attachments(msg)

assert len(result) == MAX_INBOUND_ATTACHMENTS
assert result[0].filename == "f0.txt"

@pytest.mark.asyncio
async def test_skips_attachment_that_fails_to_download(self):
adapter, _ = _bare_adapter()
att = _discord_attachment("a.png", "image/png", 10)
att.read = AsyncMock(side_effect=discord.HTTPException(MagicMock(), "boom"))
msg = MagicMock()
msg.attachments = [att]

result = await adapter._extract_attachments(msg)

assert result == ()
48 changes: 48 additions & 0 deletions autogpt_platform/backend/backend/copilot/bot/bot_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
CreateUserLinkTokenRequest,
Platform,
WorkspaceArtifact,
WorkspaceUploadRequest,
WorkspaceUploadResult,
)
from backend.util.clients import get_platform_linking_manager_client
from backend.util.exceptions import (
Expand All @@ -39,6 +41,8 @@
NotFoundError,
)

from .adapters.base import InboundAttachment

# How long to wait for a single chunk from the copilot stream before giving
# up. Covers the case where the backend crashes mid-stream and never sends
# ``StreamFinish`` — without this, the bot would hang forever on ``queue.get()``.
Expand Down Expand Up @@ -317,6 +321,48 @@ async def list_user_chats(
for s in resp.sessions
]

async def upload_workspace_files(
self,
platform: str,
platform_user_id: str,
platform_server_id: str | None,
attachments: tuple[InboundAttachment, ...],
) -> list[WorkspaceUploadResult]:
"""Upload each attachment into the conversation owner's workspace.

Returns one result per file (with a ``file_id`` on success or an
``error`` code) so the caller can attach the successes to the turn and
tell the user about any that were rejected.
"""
platform_enum = Platform(platform.upper())
results: list[WorkspaceUploadResult] = []
for attachment in attachments:
# Isolate each upload: a transport/RPC failure (or an unlinked-owner
# error) on one file must not abort the rest or crash the handler.
try:
results.append(
await self._client.upload_workspace_file(
request=WorkspaceUploadRequest(
platform=platform_enum,
platform_server_id=platform_server_id,
platform_user_id=platform_user_id,
filename=attachment.filename,
mime_type=attachment.mime_type,
content=attachment.content,
)
)
)
except Exception:
logger.exception(
"Failed to upload inbound attachment %s", attachment.filename
)
results.append(
WorkspaceUploadResult(
filename=attachment.filename, error="upload_failed"
)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return results

async def fetch_workspace_artifact(
self, session_id: str, file_id: str, max_bytes: int
) -> WorkspaceArtifact | None:
Expand All @@ -335,6 +381,7 @@ async def stream_chat(
message: str,
session_id: Optional[str] = None,
platform_server_id: Optional[str] = None,
file_ids: Optional[list[str]] = None,
on_session_id: Optional[Callable[[str], Awaitable[None]]] = None,
on_setup_required: SetupRequiredCallback | None = None,
on_setup_dropped: SetupDroppedCallback | None = None,
Expand All @@ -351,6 +398,7 @@ async def stream_chat(
message=message,
session_id=session_id,
platform_server_id=platform_server_id,
file_ids=file_ids or [],
)
)
if on_session_id:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
LinkTokenResponse,
Platform,
ResolveResponse,
WorkspaceUploadResult,
)
from backend.util.exceptions import (
DuplicateChatMessageError,
LinkAlreadyExistsError,
NotFoundError,
)

from .adapters.base import InboundAttachment
from .bot_backend import (
BotBackend,
BotStreamError,
Expand Down Expand Up @@ -403,3 +405,77 @@ def test_unparseable_non_setup_output_is_not_corrupted(self):
def test_plain_text_and_dict_outputs_are_not_corrupted(self):
assert not _is_corrupted_setup_requirements("plain text tool result")
assert not _is_corrupted_setup_requirements({"type": "setup_requirements"})


class TestUploadWorkspaceFiles:
@pytest.mark.asyncio
async def test_forwards_each_attachment_and_returns_results(self, api: BotBackend):
api._client.upload_workspace_file = AsyncMock(
side_effect=[
WorkspaceUploadResult(filename="a.png", file_id="f1"),
WorkspaceUploadResult(filename="b.exe", error="virus_detected"),
]
)

results = await api.upload_workspace_files(
platform="discord",
platform_user_id="u1",
platform_server_id="g1",
attachments=(
InboundAttachment(
filename="a.png", mime_type="image/png", content=b"x"
),
InboundAttachment(
filename="b.exe", mime_type="application/octet-stream", content=b"y"
),
),
)

assert [r.file_id for r in results] == ["f1", None]
assert results[1].error == "virus_detected"
assert api._client.upload_workspace_file.await_count == 2
first = api._client.upload_workspace_file.await_args_list[0].kwargs["request"]
assert first.platform == Platform.DISCORD
assert first.filename == "a.png"
assert first.platform_server_id == "g1"

@pytest.mark.asyncio
async def test_one_upload_failure_does_not_abort_the_rest(self, api: BotBackend):
# A transport/RPC failure on one file becomes an upload_failed result;
# later files still upload (and the handler never crashes).
api._client.upload_workspace_file = AsyncMock(
side_effect=[
RuntimeError("connection reset"),
WorkspaceUploadResult(filename="b.png", file_id="f2"),
]
)

results = await api.upload_workspace_files(
platform="discord",
platform_user_id="u1",
platform_server_id=None,
attachments=(
InboundAttachment(
filename="a.png", mime_type="image/png", content=b"x"
),
InboundAttachment(
filename="b.png", mime_type="image/png", content=b"y"
),
),
)

assert results[0].error == "upload_failed"
assert results[0].file_id is None
assert results[1].file_id == "f2"

@pytest.mark.asyncio
async def test_no_attachments_makes_no_calls(self, api: BotBackend):
api._client.upload_workspace_file = AsyncMock()
results = await api.upload_workspace_files(
platform="discord",
platform_user_id="u1",
platform_server_id=None,
attachments=(),
)
assert results == []
api._client.upload_workspace_file.assert_not_awaited()
Loading
Loading