From a25923b793f00e668b66f7bdda1aa084b0d3b868 Mon Sep 17 00:00:00 2001 From: SLAR_Edge Date: Fri, 6 Mar 2026 17:10:53 +0800 Subject: [PATCH] feat: enhance message sending to include file attachments in Discord API --- nanobot/channels/discord.py | 59 +++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index c868bbf..8672327 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -75,7 +75,7 @@ class DiscordChannel(BaseChannel): self._http = None async def send(self, msg: OutboundMessage) -> None: - """Send a message through Discord REST API.""" + """Send a message through Discord REST API, including file attachments.""" if not self._http: logger.warning("Discord HTTP client not initialized") return @@ -84,6 +84,11 @@ class DiscordChannel(BaseChannel): headers = {"Authorization": f"Bot {self.config.token}"} try: + # Send file attachments first + for media_path in msg.media or []: + await self._send_file(url, headers, media_path, reply_to=msg.reply_to) + + # Send text content chunks = split_message(msg.content or "", MAX_MESSAGE_LEN) if not chunks: return @@ -91,8 +96,8 @@ class DiscordChannel(BaseChannel): for i, chunk in enumerate(chunks): payload: dict[str, Any] = {"content": chunk} - # Only set reply reference on the first chunk - if i == 0 and msg.reply_to: + # Only set reply reference on the first chunk (if no media was sent) + if i == 0 and msg.reply_to and not msg.media: payload["message_reference"] = {"message_id": msg.reply_to} payload["allowed_mentions"] = {"replied_user": False} @@ -123,6 +128,54 @@ class DiscordChannel(BaseChannel): await asyncio.sleep(1) return False + async def _send_file( + self, + url: str, + headers: dict[str, str], + file_path: str, + reply_to: str | None = None, + ) -> bool: + """Send a file attachment via Discord REST API using multipart/form-data.""" + path = Path(file_path) + if not path.is_file(): + logger.warning("Discord file not found, skipping: {}", file_path) + return False + + if path.stat().st_size > MAX_ATTACHMENT_BYTES: + logger.warning("Discord file too large (>20MB), skipping: {}", path.name) + return False + + payload_json: dict[str, Any] = {} + if reply_to: + payload_json["message_reference"] = {"message_id": reply_to} + payload_json["allowed_mentions"] = {"replied_user": False} + + for attempt in range(3): + try: + with open(path, "rb") as f: + files = {"files[0]": (path.name, f, "application/octet-stream")} + data: dict[str, Any] = {} + if payload_json: + data["payload_json"] = json.dumps(payload_json) + response = await self._http.post( + url, headers=headers, files=files, data=data + ) + if response.status_code == 429: + resp_data = response.json() + retry_after = float(resp_data.get("retry_after", 1.0)) + logger.warning("Discord rate limited, retrying in {}s", retry_after) + await asyncio.sleep(retry_after) + continue + response.raise_for_status() + logger.info("Discord file sent: {}", path.name) + return True + except Exception as e: + if attempt == 2: + logger.error("Error sending Discord file {}: {}", path.name, e) + else: + await asyncio.sleep(1) + return False + async def _gateway_loop(self) -> None: """Main gateway loop: identify, heartbeat, dispatch events.""" if not self._ws: