1- from typing import TYPE_CHECKING , Any , Optional , Union
1+ from http import HTTPStatus
2+ from typing import TYPE_CHECKING , Any , Literal , Optional , Union
23from typing_extensions import override
34
45from nonebot .adapters import Bot as BaseBot
56
7+ from nonebot .drivers import Request
68from nonebot .message import handle_event
9+ from yarl import URL
710
811from .api import (
912 UNSET ,
1013 AllowedMention ,
1114 ApiClient ,
15+ File ,
1216 InteractionCallbackMessage ,
1317 InteractionCallbackType ,
1418 InteractionResponse ,
2832 from .adapter import Adapter
2933
3034
35+ DISCORD_ATTACHMENT_HOSTS = {"cdn.discordapp.com" , "media.discordapp.net" }
36+ AttachmentFetchOnError = Literal ["raise" , "skip" ]
37+
38+
3139async def _check_reply (bot : "Bot" , event : MessageEvent ) -> None :
3240 message_reference = event .message_reference
3341 if message_reference is UNSET :
@@ -175,6 +183,138 @@ async def handle_event(self, event: Event) -> None:
175183 _check_at_me (self , event )
176184 await handle_event (self , event )
177185
186+ async def fetch_attachments ( # noqa: PLR0913
187+ self ,
188+ message : Union [str , Message , MessageSegment ],
189+ * ,
190+ allowed_hosts : Optional [set [str ]] = None ,
191+ require_https : bool = True ,
192+ timeout : Optional [float ] = None ,
193+ max_bytes : Optional [int ] = None ,
194+ prefer_proxy_url : bool = True ,
195+ on_error : AttachmentFetchOnError = "raise" ,
196+ ) -> Message :
197+ message = MessageSegment .text (message ) if isinstance (message , str ) else message
198+ message = message if isinstance (message , Message ) else Message (message )
199+ new = message .clone ()
200+
201+ if allowed_hosts is None :
202+ allowed_hosts = DISCORD_ATTACHMENT_HOSTS
203+
204+ attachment_segments = new ["attachment" ] or []
205+ for index , attachment in enumerate (attachment_segments ):
206+ if attachment .data ["file" ] is not None :
207+ continue
208+
209+ url = self ._pick_attachment_url (
210+ attachment ,
211+ allowed_hosts = allowed_hosts ,
212+ require_https = require_https ,
213+ prefer_proxy_url = prefer_proxy_url ,
214+ )
215+ if url is None :
216+ if on_error == "raise" :
217+ msg = (
218+ f"Attachment segment at index { index } has no fetchable "
219+ "url/proxy_url"
220+ )
221+ raise ValueError (msg )
222+ continue
223+
224+ content = await self ._fetch_attachment_content (
225+ url ,
226+ timeout = timeout ,
227+ max_bytes = max_bytes ,
228+ )
229+ if content is None :
230+ if on_error == "raise" :
231+ msg = (
232+ f"Failed to fetch attachment content for segment "
233+ f"at index { index } from URL { url } "
234+ )
235+ raise ValueError (msg )
236+ continue
237+
238+ attachment .data ["file" ] = File (
239+ filename = attachment .data ["attachment" ].filename ,
240+ content = content ,
241+ )
242+
243+ return new
244+
245+ @staticmethod
246+ def _pick_attachment_url (
247+ attachment : MessageSegment ,
248+ * ,
249+ allowed_hosts : set [str ],
250+ require_https : bool ,
251+ prefer_proxy_url : bool ,
252+ ) -> Optional [str ]:
253+ urls = []
254+ if prefer_proxy_url :
255+ urls .extend (
256+ [
257+ attachment .data .get ("proxy_url" ),
258+ attachment .data .get ("url" ),
259+ ]
260+ )
261+ else :
262+ urls .extend (
263+ [
264+ attachment .data .get ("url" ),
265+ attachment .data .get ("proxy_url" ),
266+ ]
267+ )
268+
269+ for candidate in urls :
270+ if isinstance (candidate , str ) and Bot ._is_supported_attachment_url (
271+ candidate ,
272+ allowed_hosts = allowed_hosts ,
273+ require_https = require_https ,
274+ ):
275+ return candidate
276+
277+ return None
278+
279+ @staticmethod
280+ def _is_supported_attachment_url (
281+ url : str , * , allowed_hosts : set [str ], require_https : bool
282+ ) -> bool :
283+ parsed = URL (url )
284+ scheme_ok = parsed .scheme == "https" if require_https else bool (parsed .scheme )
285+ return (
286+ scheme_ok and isinstance (parsed .host , str ) and parsed .host in allowed_hosts
287+ )
288+
289+ async def _fetch_attachment_content (
290+ self ,
291+ url : str ,
292+ * ,
293+ timeout : Optional [float ],
294+ max_bytes : Optional [int ],
295+ ) -> Optional [bytes ]:
296+ try :
297+ request = Request (
298+ method = "GET" ,
299+ url = url ,
300+ timeout = timeout or self .adapter .discord_config .discord_api_timeout ,
301+ proxy = self .adapter .discord_config .discord_proxy ,
302+ )
303+ response = await self .adapter .request (request )
304+ if response .status_code != HTTPStatus .OK or not response .content :
305+ return None
306+ content = (
307+ response .content .encode ()
308+ if isinstance (response .content , str )
309+ else response .content
310+ )
311+ if max_bytes is not None and len (content ) > max_bytes :
312+ return None
313+ return content # noqa: TRY300
314+ except Exception as e :
315+ log ("DEBUG" , f"Failed to fetch attachment content from URL { url } : { e !r} " , e )
316+ return None
317+
178318 async def send_to (
179319 self ,
180320 channel_id : SnowflakeType ,
@@ -183,6 +323,9 @@ async def send_to(
183323 nonce : Union [int , str , None ] = None ,
184324 allowed_mentions : Optional [AllowedMention ] = None ,
185325 ) -> MessageGet :
326+ message = MessageSegment .text (message ) if isinstance (message , str ) else message
327+ message = message if isinstance (message , Message ) else Message (message )
328+ message = message .sendable ()
186329 message_data = parse_message (message )
187330
188331 return await self .create_message (
@@ -222,6 +365,8 @@ async def send(
222365 message model
223366 """
224367 message = MessageSegment .text (message ) if isinstance (message , str ) else message
368+ message = message if isinstance (message , Message ) else Message (message )
369+ message = message .sendable ()
225370 if isinstance (event , InteractionCreateEvent ):
226371 message_data = parse_message (message )
227372 response = InteractionResponse (
@@ -250,7 +395,6 @@ async def send(
250395 if not isinstance (event , MessageEvent ) or not event .channel_id or not event .id :
251396 msg = "Event cannot be replied to!"
252397 raise RuntimeError (msg )
253- message = message if isinstance (message , Message ) else Message (message )
254398 if mention_sender or at_sender :
255399 message .insert (0 , MessageSegment .mention_user (event .user_id ))
256400 if reply_message :
0 commit comments