Coverage for slidge/core/mixins/attachment.py: 88%
444 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 18:29 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 18:29 +0000
1from __future__ import annotations
3import base64
4import contextlib
5import functools
6import io
7import logging
8import os
9import shutil
10import stat
11import tempfile
12import warnings
13from collections.abc import AsyncIterator, Collection, Sequence
14from datetime import datetime
15from itertools import chain
16from mimetypes import guess_extension, guess_type
17from pathlib import Path
18from typing import IO, Any, cast
19from urllib.parse import quote as urlquote
20from uuid import uuid4
21from xml.etree import ElementTree as ET
23import aiohttp
24import thumbhash
25from PIL import Image, ImageOps
26from slixmpp import JID, Iq, Message
27from slixmpp.plugins.xep_0264.stanza import Thumbnail
28from slixmpp.plugins.xep_0447.stanza import StatelessFileSharing
30from ...db.avatar import avatar_cache
31from ...db.models import Attachment
32from ...util.types import LegacyAttachment, MessageReference
33from ...util.util import fix_namespaces, fix_suffix
34from .. import config
35from .message_text import TextMessageMixin
38class AttachmentMixin(TextMessageMixin):
39 @property
40 def __is_component(self) -> bool:
41 return self.session is NotImplemented
43 async def __upload(self, att: _AttachmentWithName) -> str:
44 assert config.UPLOAD_SERVICE
45 att = await _ensure_metadata(att)
46 iq_slot = await self.__request_upload_slot(
47 config.UPLOAD_SERVICE,
48 att.name,
49 att.size,
50 att.content_type,
51 )
52 if iq_slot["type"] == "error":
53 # COMPAT: In theory, __request_upload_slot() raises IqError, but
54 # there is a bug in prosody's mod_privilege where the outer
55 # IQ type is (illegally) not set to error when the inner IQ
56 # has type='error'.
57 raise RuntimeError(f"Error while requesting upload slot: {iq_slot}")
58 slot = iq_slot.get_plugin("http_upload_slot", check=True)
59 if slot is None:
60 raise RuntimeError(f"No upload slot in this IQ: {iq_slot}")
61 put = slot["put"]["url"]
62 assert isinstance(put, str)
63 if not put:
64 raise RuntimeError(f"Cannot find a PUT URL in: {slot}")
65 get = slot["get"]["url"]
66 assert isinstance(get, str)
67 if not get:
68 raise RuntimeError(f"Cannot find a GET URL in: {slot}")
69 headers = {
70 "Content-Length": str(att.size),
71 "Content-Type": att.content_type,
72 **{header["name"]: header["value"] for header in slot["put"]["headers"]},
73 }
75 async with (
76 aiohttp.ClientSession() as http,
77 _get_data(att, http) as data,
78 http.put(slot["put"]["url"], data=data, headers=headers) as resp,
79 ):
80 resp.raise_for_status()
82 return get
84 async def __request_upload_slot(
85 self,
86 upload_service: JID | str,
87 filename: str,
88 size: int,
89 content_type: str,
90 ) -> Iq:
91 iq_request = self.xmpp.make_iq_get(ito=upload_service)
92 request = iq_request["http_upload_request"]
93 request["filename"] = filename
94 request["size"] = str(size)
95 request["content-type"] = content_type
96 if not self.__is_component:
97 iq_request.set_from(self.session.user_jid)
98 try:
99 return await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq_request)
100 except Exception as e: # noqa: BLE001
101 warnings.warn(
102 "Could not request upload slot on behalf of "
103 f"{self.session.user_jid}: {e}."
104 "Falling back to not using privileges."
105 )
106 fix_namespaces(iq_request.xml, "jabber:client", "jabber:component:accept")
107 iq_request.set_from(config.UPLOAD_REQUESTER or self.xmpp.boundjid)
108 return await iq_request.send() # type:ignore[no-any-return]
110 @staticmethod
111 async def __no_upload(
112 att: _AttachmentWithName, legacy_file_id: str | None
113 ) -> tuple[Path, str]:
114 file_id = uuid4().hex if legacy_file_id is None else legacy_file_id
115 assert config.NO_UPLOAD_PATH is not None
116 assert config.NO_UPLOAD_URL_PREFIX is not None
117 destination_dir = Path(config.NO_UPLOAD_PATH) / file_id
119 if destination_dir.exists():
120 log.debug("Dest dir exists: %s", destination_dir)
121 files = [f for f in destination_dir.glob("**/*") if f.is_file()]
122 if len(files) == 1:
123 log.debug(
124 "Found the legacy attachment '%s' at '%s'",
125 legacy_file_id,
126 files[0],
127 )
128 name = files[0].name
129 uu = files[0].parent.name # anti-obvious url trick, see below
130 return files[0], f"{file_id}/{uu}/{name}"
131 else:
132 log.warning(
133 (
134 "There are several or zero files in %s, "
135 "slidge doesn't know which one to pick among %s. "
136 "Removing the dir."
137 ),
138 destination_dir,
139 files,
140 )
141 shutil.rmtree(destination_dir)
143 log.debug("Did not find a file in: %s", destination_dir)
144 # let's use a UUID to avoid URLs being too obvious
145 uu = str(uuid4())
146 destination_dir = destination_dir / uu
147 destination_dir.mkdir(parents=True)
149 assert att.name
150 destination = destination_dir / att.name
151 if att.path:
152 assert isinstance(att.path, Path)
153 try:
154 destination.hardlink_to(att.path)
155 except OSError as e:
156 if is_temp_path(att.path):
157 shutil.copy2(att.path, destination)
158 else:
159 log.debug("Could not hardlink: %s, attempting symlink", e)
160 try:
161 destination.symlink_to(att.path)
162 except OSError as e:
163 log.debug("Could not symlink: %s, copying data", e)
164 shutil.copy2(att.path, destination)
165 elif att.data:
166 destination.write_bytes(att.data)
167 else:
168 with destination.open("wb") as f:
169 if att.aio_stream:
170 async for chunk in att.aio_stream:
171 f.write(chunk)
172 elif att.stream:
173 shutil.copyfileobj(att.stream, f)
174 elif att.url:
175 async with (
176 aiohttp.ClientSession() as http,
177 http.get(att.url) as resp,
178 ):
179 resp.raise_for_status()
180 async for chunk in resp.content.iter_chunked(64 * 1024):
181 f.write(chunk)
182 else:
183 raise RuntimeError
185 if config.NO_UPLOAD_FILE_READ_OTHERS:
186 log.debug("Changing perms of %s", destination)
187 destination.chmod(destination.stat().st_mode | stat.S_IROTH)
189 url = f"{file_id}/{uu}/{att.name}"
190 return destination, url
192 async def __valid_url(self, url: str) -> bool:
193 async with self.session.http.head(url) as r:
194 return r.status < 400
196 async def __get_stored(self, attachment: LegacyAttachment) -> Attachment:
197 if attachment.legacy_file_id is not None and not self.__is_component:
198 with self.xmpp.store.session() as orm:
199 stored = (
200 orm.query(Attachment)
201 .filter_by(
202 legacy_file_id=str(attachment.legacy_file_id),
203 user_account_id=self.session.user_pk,
204 )
205 .one_or_none()
206 )
207 if stored is not None:
208 if not await self.__valid_url(stored.url):
209 stored.url = None # type:ignore
210 return stored
211 return Attachment(
212 user_account_id=None if self.__is_component else self.session.user_pk,
213 legacy_file_id=None
214 if attachment.legacy_file_id is None
215 else str(attachment.legacy_file_id),
216 url=attachment.url if config.USE_ATTACHMENT_ORIGINAL_URLS else None,
217 )
219 async def __get_url(self, att: LegacyAttachment, stored: Attachment) -> str:
220 att = _ensure_name(att)
222 if len(att.name) > config.ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH:
223 log.debug("Trimming long filename: %s", att.name)
224 base, ext = os.path.splitext(att.name)
225 att.name = (
226 base[: config.ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH - len(ext)] + ext
227 )
229 if config.FIX_FILENAME_SUFFIX_MIME_TYPE and isinstance(att.path, Path):
230 att.name, att.content_type = fix_suffix(
231 att.path, att.content_type, att.name
232 )
234 att.legacy_file_id = stored.legacy_file_id
236 if config.NO_UPLOAD_PATH:
237 att.path, new_url = await self.__no_upload(att, stored.legacy_file_id)
238 new_url = (config.NO_UPLOAD_URL_PREFIX or "") + "/" + urlquote(new_url)
239 else:
240 new_url = await self.__upload(att)
242 if stored.legacy_file_id:
243 stored.url = new_url
245 return new_url
247 def __set_sims(
248 self,
249 msg: Message,
250 uploaded_url: str,
251 attachment: LegacyAttachment,
252 stored: Attachment,
253 thumbnail: Thumbnail | None,
254 ) -> None:
255 if stored.sims is not None:
256 ref = self.xmpp.plugin["xep_0372"].stanza.Reference(
257 xml=ET.fromstring(stored.sims)
258 )
259 msg.append(ref)
260 return
262 if attachment.data or attachment.stream or attachment.path:
263 ref = self.xmpp.plugin["xep_0385"].get_sims(
264 attachment.path, # type:ignore[arg-type]
265 [uploaded_url],
266 attachment.content_type,
267 attachment.caption,
268 file=attachment.stream,
269 data=attachment.data,
270 )
271 else:
272 sims = self.xmpp.plugin["xep_0385"].stanza.Sims()
273 ref = self.xmpp.plugin["xep_0372"].stanza.Reference()
274 ref["uri"] = uploaded_url
275 ref["type"] = "data"
276 sims["sources"].append(ref)
277 ref = self.xmpp.plugin["xep_0372"].stanza.Reference()
278 ref.append(sims)
279 ref["type"] = "data"
280 sims.enable("file")
281 if attachment.content_type:
282 sims["file"]["media-type"] = attachment.content_type
283 if attachment.caption:
284 sims["file"]["desc"] = attachment.caption
285 if attachment.size:
286 sims["file"]["size"] = attachment.size
287 if attachment.name:
288 ref["sims"]["file"]["name"] = attachment.name
289 if attachment.aio_stream is not None:
290 # revove the date in case we have downloaded the file to disk
291 # because no size was provided by the legacy module
292 ref["sims"]["file"]["date"] = None
294 if thumbnail is not None:
295 ref["sims"]["file"].append(thumbnail)
297 stored.sims = str(ref)
298 msg.append(ref)
300 def __set_sfs(
301 self,
302 msg: Message,
303 uploaded_url: str,
304 attachment: LegacyAttachment,
305 stored: Attachment,
306 thumbnail: Thumbnail | None = None,
307 ) -> None:
308 if stored.sfs is not None:
309 msg.append(StatelessFileSharing(xml=ET.fromstring(stored.sfs)))
310 return
312 if attachment.path or attachment.data or attachment.stream:
313 sfs = self.xmpp.plugin["xep_0447"].get_sfs( # type:ignore
314 attachment.path,
315 [uploaded_url],
316 attachment.content_type,
317 attachment.caption,
318 data=attachment.data,
319 file=attachment.stream,
320 )
321 else:
322 sfs = self.xmpp.plugin["xep_0447"].stanza.StatelessFileSharing()
323 ref = self.xmpp.plugin["xep_0447"].stanza.UrlData()
324 ref["target"] = uploaded_url
325 sfs["sources"].append(ref)
326 sfs.enable("file")
327 if attachment.content_type:
328 sfs["file"]["media-type"] = attachment.content_type
329 if attachment.name:
330 sfs["file"]["name"] = attachment.name
331 if attachment.disposition:
332 sfs["disposition"] = attachment.disposition
333 else:
334 del sfs["disposition"]
335 if thumbnail is not None:
336 sfs["file"].append(thumbnail)
337 if attachment.aio_stream is not None:
338 # revove the date in case we have downloaded the file to disk
339 # because no size was provided by the legacy module
340 sfs["file"]["date"] = None
341 if attachment.size:
342 sfs["file"]["size"] = attachment.size
343 stored.sfs = str(sfs)
344 msg.append(sfs)
346 def __send_url(
347 self,
348 msg: Message,
349 legacy_msg_id: str | None,
350 uploaded_url: str,
351 caption: str | None = None,
352 carbon: bool = False,
353 when: datetime | None = None,
354 correction: bool = False,
355 **kwargs: Any, # noqa:ANN401
356 ) -> list[Message]:
357 msg["oob"]["url"] = uploaded_url
358 msg["body"] = uploaded_url
359 if "sfs" in msg:
360 msg["fallback"].enable("body")
361 msg["fallback"]["for"] = self.xmpp.plugin["xep_0447"].stanza.NAMESPACE
362 if caption:
363 if correction:
364 if not legacy_msg_id:
365 raise TypeError
366 msg["replace"]["id"] = self._replace_id(legacy_msg_id)
367 elif legacy_msg_id:
368 self._set_msg_id(msg, legacy_msg_id)
369 m1 = self._send(msg, carbon=carbon, correction=correction, **kwargs)
370 m2 = self.send_text(
371 caption, legacy_msg_id=None, when=when, carbon=carbon, **kwargs
372 )
373 return [m1, m2] if m2 else [m1]
374 else:
375 if correction:
376 if not legacy_msg_id:
377 raise TypeError
378 msg["replace"]["id"] = self._replace_id(legacy_msg_id)
379 elif legacy_msg_id:
380 self._set_msg_id(msg, legacy_msg_id)
381 return [self._send(msg, carbon=carbon, **kwargs)]
383 def __get_base_message(
384 self,
385 legacy_msg_id: str | None = None,
386 reply_to: MessageReference | None = None,
387 when: datetime | None = None,
388 thread: str | None = None,
389 carbon: bool = False,
390 correction: bool = False,
391 mto: JID | None = None,
392 ) -> Message:
393 if correction:
394 if not legacy_msg_id:
395 raise TypeError
396 xmpp_ids = self._legacy_to_xmpp(legacy_msg_id)
397 if xmpp_ids:
398 original_xmpp_id = xmpp_ids[0]
399 for xmpp_id in xmpp_ids:
400 if xmpp_id == original_xmpp_id:
401 continue
402 self.retract(xmpp_id, thread)
404 if reply_to is not None and reply_to.body:
405 # We cannot have a "quote fallback" for attachments since most (all?)
406 # XMPP clients will only treat a message as an attachment if the
407 # body is the URL and nothing else.
408 reply_to_for_attachment: MessageReference | None = MessageReference(
409 reply_to.legacy_id, reply_to.author
410 )
411 else:
412 reply_to_for_attachment = reply_to
414 return self._make_message(
415 when=when,
416 reply_to=reply_to_for_attachment,
417 carbon=carbon,
418 mto=mto,
419 thread=thread,
420 )
422 async def send_file(
423 self,
424 attachment: LegacyAttachment | Path | str,
425 legacy_msg_id: str | None = None,
426 *,
427 reply_to: MessageReference | None = None,
428 when: datetime | None = None,
429 thread: str | None = None,
430 **kwargs: Any, # noqa:ANN401
431 ) -> tuple[str | None, list[Message]]:
432 """
433 Send a single file from this :term:`XMPP Entity`.
435 :param attachment: The file to send.
436 Ideally, a :class:`.LegacyAttachment` with a unique ``legacy_file_id``
437 attribute set, to optimise potential future reuses.
438 It can also be:
439 - a :class:`pathlib.Path` instance to point to a local file, or
440 - a ``str``, representing a fetchable HTTP URL.
441 :param legacy_msg_id: If you want to be able to transport read markers from the gateway
442 user to the legacy network, specify this
443 :param reply_to: Quote another message (:xep:`0461`)
444 :param when: when the file was sent, for a "delay" tag (:xep:`0203`)
445 :param thread:
446 """
447 coro = self.__send_file(
448 attachment,
449 legacy_msg_id,
450 reply_to=reply_to,
451 when=when,
452 thread=thread,
453 **kwargs,
454 )
455 if (
456 self.__is_component
457 or not isinstance(attachment, LegacyAttachment)
458 or attachment.legacy_file_id is None
459 ):
460 return await coro
461 else:
462 # prevents race conditions where we download the same thing several time
463 # and end up attempting to insert it twice in the DB, raising an
464 # IntegrityError.
465 async with self.session.lock(("attachment", attachment.legacy_file_id)):
466 return await coro
468 async def __send_file(
469 self,
470 attachment: LegacyAttachment | Path | str,
471 legacy_msg_id: str | None = None,
472 *,
473 reply_to: MessageReference | None = None,
474 when: datetime | None = None,
475 thread: str | None = None,
476 store_multi: bool = True,
477 carbon: bool = False,
478 mto: JID | None = None,
479 correction: bool = False,
480 **send_kwargs: Any, # noqa:ANN401
481 ) -> tuple[str | None, list[Message]]:
482 if isinstance(attachment, str):
483 attachment = LegacyAttachment(url=attachment)
484 elif isinstance(attachment, Path):
485 attachment = LegacyAttachment(path=attachment)
487 msg = self.__get_base_message(
488 legacy_msg_id=legacy_msg_id,
489 reply_to=reply_to,
490 when=when,
491 thread=thread,
492 carbon=carbon,
493 correction=correction,
494 mto=mto,
495 )
496 if attachment.is_sticker:
497 msg.enable("sticker")
499 stored = await self.__get_stored(attachment)
501 if attachment.content_type is None and (
502 name := (attachment.name or attachment.url or attachment.path)
503 ):
504 attachment.content_type, _ = guess_type(name)
506 if not attachment.name:
507 if attachment.url:
508 attachment.url.split("/")[-1]
509 elif isinstance(attachment.path, Path):
510 attachment.name = attachment.path.name
511 else:
512 attachment.name = uuid4().hex
513 if attachment.content_type:
514 ext = guess_extension(attachment.content_type)
515 if ext:
516 attachment.name += ext
518 if stored.url:
519 new_url = stored.url
520 else:
521 try:
522 new_url = await self.__get_url(attachment, stored)
523 except Exception as e:
524 log.error("Error with attachment: %s: %s", attachment, e)
525 log.debug("", exc_info=e)
526 msg["body"] = (
527 f"/me tried to send a file ({attachment.format_for_user()}), "
528 f"but something went wrong: {e}. "
529 )
530 self._set_msg_id(msg, legacy_msg_id)
531 return None, [self._send(msg, **send_kwargs)]
532 stored.url = new_url
533 attachment.url = new_url
534 thumbnail = await self.__get_thumbnail(attachment)
535 self.__set_sims(msg, new_url, attachment, stored, thumbnail)
536 self.__set_sfs(msg, new_url, attachment, stored, thumbnail)
538 if isinstance(attachment.path, Path) and is_temp_path(
539 attachment.path, async_iterator_download_only=True
540 ):
541 attachment.path.unlink()
542 attachment.path = None
544 if not self.__is_component:
545 with self.xmpp.store.session(expire_on_commit=False) as orm:
546 orm.add(stored)
547 orm.commit()
549 msgs = self.__send_url(
550 msg,
551 legacy_msg_id,
552 uploaded_url=new_url,
553 caption=attachment.caption,
554 carbon=carbon,
555 when=when,
556 correction=correction,
557 **send_kwargs,
558 )
559 if not self.__is_component and store_multi and legacy_msg_id:
560 self.__store_multi(legacy_msg_id, msgs)
562 return new_url, msgs
564 def __send_body(
565 self,
566 body: str | None = None,
567 legacy_msg_id: str | None = None,
568 reply_to: MessageReference | None = None,
569 when: datetime | None = None,
570 thread: str | None = None,
571 **kwargs: Any, # noqa:ANN401
572 ) -> Message | None:
573 if body:
574 return self.send_text(
575 body,
576 legacy_msg_id,
577 reply_to=reply_to,
578 when=when,
579 thread=thread,
580 **kwargs,
581 )
582 else:
583 return None
585 async def send_files(
586 self,
587 attachments: Collection[LegacyAttachment],
588 legacy_msg_id: str | None = None,
589 body: str | None = None,
590 *,
591 reply_to: MessageReference | None = None,
592 when: datetime | None = None,
593 thread: str | None = None,
594 body_first: bool = False,
595 correction: bool = False,
596 correction_event_id: str | None = None,
597 **kwargs: Any, # noqa:ANN401
598 ) -> None:
599 # TODO: once the epic XEP-0385 vs XEP-0447 battle is over, pick
600 # one and stop sending several attachments this way
601 # we attach the legacy_message ID to the last message we send, because
602 # we don't want several messages with the same ID (especially for MUC MAM)
603 if not attachments and not body:
604 # ignoring empty message
605 return
606 body_msg_id = (
607 legacy_msg_id if body_needs_msg_id(attachments, body, body_first) else None
608 )
609 send_body = functools.partial(
610 self.__send_body,
611 body=body,
612 reply_to=reply_to,
613 when=when,
614 thread=thread,
615 correction=correction,
616 legacy_msg_id=body_msg_id,
617 correction_event_id=correction_event_id,
618 **kwargs,
619 )
620 all_msgs = []
621 if body_first:
622 all_msgs.append(send_body())
623 for i, attachment in enumerate(attachments):
624 legacy = legacy_msg_id if i == 0 and body_msg_id is None else None
625 _url, msgs = await self.send_file(
626 attachment,
627 legacy,
628 reply_to=reply_to,
629 when=when,
630 thread=thread,
631 store_multi=False,
632 **kwargs,
633 )
634 all_msgs.extend(msgs)
635 if not body_first:
636 all_msgs.append(send_body())
637 self.__store_multi(legacy_msg_id, all_msgs)
639 def __store_multi(
640 self,
641 legacy_msg_id: str | None,
642 all_msgs: Sequence[Message | None],
643 ) -> None:
644 if legacy_msg_id is None:
645 return
646 ids = []
647 for msg in all_msgs:
648 if not msg:
649 continue
650 if stanza_id := msg.get_plugin("stanza_id", check=True):
651 ids.append(stanza_id["id"])
652 else:
653 ids.append(msg.get_id())
654 with self.xmpp.store.session() as orm:
655 self.xmpp.store.id_map.set_msg(
656 orm, self._recipient_pk(), str(legacy_msg_id), ids, self.is_participant
657 )
658 orm.commit()
660 async def __get_thumbnail(self, attachment: LegacyAttachment) -> Thumbnail | None:
661 if attachment.content_type is None:
662 return None
663 if not (attachment.data or attachment.stream or attachment.path):
664 return None
665 if not attachment.content_type.startswith("image"):
666 return None
668 try:
669 h, x, y = await self.xmpp.loop.run_in_executor(
670 avatar_cache._thread_pool,
671 get_thumbhash,
672 attachment,
673 )
674 except Exception as e:
675 log.debug("Could not generate a thumbhash", exc_info=e)
676 return None
678 thumbnail = Thumbnail()
679 thumbnail["width"] = x
680 thumbnail["height"] = y
681 thumbnail["media-type"] = "image/thumbhash"
682 thumbnail["uri"] = "data:image/thumbhash;base64," + urlquote(h)
683 return thumbnail
686class _AttachmentWithName(LegacyAttachment):
687 name: str
688 path: Path | None
691class _AttachmentWithMetadata(_AttachmentWithName):
692 size: int
693 content_type: str
696def _ensure_name(att: LegacyAttachment) -> _AttachmentWithName:
697 if not att.name:
698 if att.path:
699 att.name = Path(att.path).name
700 elif att.url:
701 att.name = att.url.split("/")[-1]
702 else:
703 att.name = "unnamed-file"
704 return cast(_AttachmentWithName, att)
707async def _ensure_metadata(att: _AttachmentWithName) -> _AttachmentWithMetadata:
708 if att.size is None:
709 if att.data:
710 att.size = len(att.data)
711 elif att.stream:
712 att.stream.seek(0, io.SEEK_END)
713 att.size = att.stream.tell()
714 att.stream.seek(0)
715 elif att.path:
716 assert isinstance(att.path, Path)
717 att.size = att.path.stat().st_size
718 elif att.url:
719 async with (
720 aiohttp.ClientSession() as http,
721 http.head(att.url) as resp,
722 ):
723 att.size = resp.content_length
724 elif att.aio_stream:
725 warnings.warn("A size should be passed with async iterators")
726 tmp_dir = Path(tempfile.mkdtemp(prefix=_TEMP_PREFIX))
727 with (tmp_dir / att.name).open("wb") as fp:
728 async for chunk in att.aio_stream:
729 fp.write(chunk)
730 att.path = Path(fp.name)
731 att.size = att.path.stat().st_size
733 if not att.content_type:
734 att.content_type, _encoding = guess_type(att.name)
735 if not att.content_type:
736 att.content_type = "application/octet-stream"
738 return cast(_AttachmentWithMetadata, att)
741def body_needs_msg_id(
742 attachments: Collection[LegacyAttachment], body: str | None, body_first: bool
743) -> bool:
744 if attachments:
745 return bool(body and body_first)
746 else:
747 return True
750def get_thumbhash(att: LegacyAttachment) -> tuple[str, int, int]:
751 img, width, height = get_image(att)
752 rgba_2d = list(img.get_flattened_data())
753 rgba = list(chain(*rgba_2d)) # type:ignore[arg-type,var-annotated]
754 ints = thumbhash.rgba_to_thumb_hash(img.width, img.height, rgba)
755 return base64.b64encode(bytes(ints)).decode(), width, height
758def get_image(att: LegacyAttachment) -> tuple[Image.Image, int, int]:
759 if att.data:
760 img = Image.open(io.BytesIO(att.data))
761 elif att.path:
762 with cast(Path, att.path).open("rb") as fp:
763 img = Image.open(fp)
764 img.load()
765 elif att.stream:
766 att.stream.seek(0)
767 img = Image.open(att.stream)
768 else:
769 raise RuntimeError("No way to read the image")
770 width, height = img.size
771 rgba = img.convert("RGBA")
772 if width > 100 or height > 100:
773 rgba.thumbnail((100, 100))
774 return ImageOps.exif_transpose(rgba), width, height
777def is_temp_path(path: Path, async_iterator_download_only: bool = False) -> bool:
778 try:
779 rel = path.relative_to(_TEMP_ROOT)
780 except ValueError:
781 return False
782 if async_iterator_download_only:
783 return rel.parts[0].startswith(_TEMP_PREFIX)
784 else:
785 return True
788@contextlib.asynccontextmanager
789async def _get_data(
790 att: LegacyAttachment, http: aiohttp.ClientSession
791) -> AsyncIterator[bytes | IO[bytes] | AsyncIterator[bytes]]:
792 if (data := att.data or att.stream) is not None:
793 yield data
794 elif att.path is not None:
795 assert isinstance(att.path, Path)
796 with att.path.open("rb") as fp:
797 yield fp
798 elif att.aio_stream is not None:
799 # The aiostream may already have been consumed if a size wasn't passed.
800 # But in this case the `path`attribute is not Nonce, cf above.
801 yield att.aio_stream
802 elif att.url is not None:
803 async with http.get(att.url) as resp_get:
804 resp_get.raise_for_status()
805 yield resp_get.content.iter_any()
806 else:
807 raise RuntimeError("NEVER")
810_TEMP_ROOT = Path(tempfile.gettempdir())
811_TEMP_PREFIX = "slidge-async-iterator-download"
814log = logging.getLogger(__name__)