Coverage for slidge/core/mixins/message_maker.py: 86%
139 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-18 04:30 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-18 04:30 +0000
1import base64
2import io
3import logging
4import uuid
5import warnings
6from collections.abc import Iterable
7from datetime import UTC, datetime
8from pathlib import Path
9from typing import cast
11from PIL import Image
12from slixmpp import Message
13from slixmpp.plugins.xep_0511.stanza import LinkMetadata
14from slixmpp.types import MessageTypes
16from slidge.util import strip_illegal_chars
18from ...db.models import GatewayUser
19from ...util.types import (
20 AnyMUC,
21 AnyParticipant,
22 ChatState,
23 LinkPreview,
24 MessageReference,
25 ProcessingHint,
26)
27from .. import config
28from .base import BaseSender
31class MessageMaker(BaseSender):
32 mtype: MessageTypes = NotImplemented
33 _can_send_carbon: bool = NotImplemented
34 STRIP_SHORT_DELAY = False
35 USE_STANZA_ID = False
37 muc: AnyMUC
39 def _recipient_pk(self) -> int | None:
40 """Primary key of receiver of messages made."""
41 return None
43 def _make_message(
44 self,
45 state: ChatState | None = None,
46 hints: Iterable[ProcessingHint] = (),
47 legacy_msg_id: str | None = None,
48 when: datetime | None = None,
49 reply_to: MessageReference | None = None,
50 carbon: bool = False,
51 link_previews: Iterable[LinkPreview] | None = None,
52 **kwargs: object,
53 ) -> Message:
54 body = kwargs.pop("mbody", None)
55 mfrom = kwargs.pop("mfrom", self.jid)
56 mto = kwargs.pop("mto", None)
57 thread = kwargs.pop("thread", None)
58 # the msg needs to have jabber:client as xmlns, so
59 # we don't want to associate with the XML stream
60 msg_cls = Message if carbon and self._can_send_carbon else self.xmpp.Message
61 msg = msg_cls(
62 sfrom=mfrom,
63 stype=kwargs.pop("mtype", None) or self.mtype,
64 sto=mto,
65 **kwargs,
66 )
67 if body:
68 assert isinstance(body, str)
69 msg["body"] = strip_illegal_chars(body, "�")
70 state = "active"
71 if thread:
72 assert isinstance(thread, str)
73 if (recipient_pk := self._recipient_pk()) is None:
74 # messages from the gateway component bare JID
75 msg["thread"] = thread
76 else:
77 with self.xmpp.store.session() as orm:
78 msg["thread"] = (
79 self.xmpp.store.id_map.get_thread(
80 orm, recipient_pk, thread, self.is_participant
81 )
82 or thread
83 )
84 if state:
85 msg["chat_state"] = state
86 for hint in hints:
87 msg.enable(hint)
88 self._set_msg_id(msg, legacy_msg_id)
89 self._add_delay(msg, when)
90 if link_previews:
91 self._add_link_previews(msg, link_previews)
92 if reply_to:
93 self._add_reply_to(msg, reply_to)
94 return msg
96 def _set_msg_id(self, msg: Message, legacy_msg_id: str | None = None) -> None:
97 if legacy_msg_id is not None:
98 msg.set_id(legacy_msg_id)
99 if self.USE_STANZA_ID:
100 msg["stanza_id"]["id"] = legacy_msg_id
101 msg["stanza_id"]["by"] = self.muc.jid
102 elif self.USE_STANZA_ID:
103 msg["stanza_id"]["id"] = str(uuid.uuid4())
104 msg["stanza_id"]["by"] = self.muc.jid
106 def _legacy_to_xmpp(self, legacy_id: str) -> list[str]:
107 # In the case of messages sent by the component's bare JID itself,
108 # recipient_pk is None and this just wraps the ID in a list.
109 if (recipient_pk := self._recipient_pk()) is not None:
110 with self.xmpp.store.session() as orm:
111 ids = self.xmpp.store.id_map.get_xmpp(
112 orm,
113 recipient_pk,
114 str(legacy_id),
115 self.is_participant,
116 )
117 if ids:
118 return ids
119 return [legacy_id]
121 def _add_delay(self, msg: Message, when: datetime | None) -> None:
122 if when:
123 if when.tzinfo is None:
124 when = when.astimezone(UTC)
125 if self.STRIP_SHORT_DELAY:
126 delay = (datetime.now().astimezone(UTC) - when).seconds
127 if delay < config.IGNORE_DELAY_THRESHOLD:
128 return
129 msg["delay"].set_stamp(when)
130 msg["delay"].set_from(self.xmpp.boundjid.bare)
132 def _add_reply_to(self, msg: Message, reply_to: MessageReference) -> None:
133 xmpp_id = self._legacy_to_xmpp(reply_to.legacy_id)[0]
134 msg["reply"]["id"] = xmpp_id
136 muc = getattr(self, "muc", None)
138 if entity := reply_to.author:
139 if entity == "user" or isinstance(entity, GatewayUser):
140 if isinstance(entity, GatewayUser):
141 warnings.warn(
142 "Using a GatewayUser as the author of a "
143 "MessageReference is deprecated. Use the string 'user' "
144 "instead.",
145 DeprecationWarning,
146 )
147 if muc:
148 msg["reply"]["to"] = muc.user_muc_jid
149 fallback_nick = muc.user_nick
150 elif (session := self.session) is not None:
151 msg["reply"]["to"] = session.user_jid
152 # TODO: here we should use preferably use the PEP nick of the user
153 # (but it doesn't matter much)
154 fallback_nick = session.user_jid.user
155 else:
156 if muc:
157 if hasattr(entity, "muc"):
158 # TODO: accept a Contact here and use muc.get_participant_by_legacy_id()
159 # a bit of work because right now this is a sync function
160 entity = cast(AnyParticipant, entity)
161 fallback_nick = entity.nickname
162 else:
163 warnings.warn(
164 "The author of a message reference in a MUC must be a"
165 " Participant instance, not a Contact"
166 )
167 fallback_nick = entity.name
168 else:
169 fallback_nick = entity.name
170 msg["reply"]["to"] = entity.jid
171 else:
172 fallback_nick = None
174 if fallback := reply_to.body:
175 msg["reply"].add_quoted_fallback(fallback, fallback_nick)
177 def _add_link_previews(
178 self, msg: Message, link_previews: Iterable[LinkPreview]
179 ) -> None:
180 for preview in link_previews:
181 if preview.is_empty:
182 continue
183 element = LinkMetadata()
184 for i, name in enumerate(preview._fields):
185 val = preview[i]
186 if isinstance(val, Path):
187 val = val.read_bytes()
188 if isinstance(val, bytes):
189 val = self._process_link_preview_image(val)
190 if not val:
191 continue
192 element[name] = val
193 msg.append(element)
195 @staticmethod
196 def _process_link_preview_image(data: bytes) -> str | None:
197 # this will block the main thread. if this proves to be an issue in practice,
198 # this could be rewritten to use the thread pool we use to resize avatars.
199 try:
200 image = Image.open(io.BytesIO(data))
201 except Exception:
202 log.exception("Skipping link preview image")
203 return None
205 rewrite = False
206 if image.format != "JPEG":
207 rewrite = True
209 if any(x > MAX_LINK_PREVIEW_IMAGE_SIZE for x in image.size):
210 image.thumbnail((MAX_LINK_PREVIEW_IMAGE_SIZE, MAX_LINK_PREVIEW_IMAGE_SIZE))
211 rewrite = True
213 if rewrite:
214 with io.BytesIO() as f:
215 image.save(f, format="JPEG")
216 data = f.getvalue()
218 return "data:image/jpeg;base64," + base64.b64encode(data).decode("utf-8")
221# Instead of having a hardcoded value for this, we would ideally use
222# XEP-0478: Stream Limits Advertisement to know which size is authorized.
223# However, this isn't possible until XEP-0225: Component Connections is a thing.
224# Prosody defaults to 512kb for s2s connection and 10Mb for c2s connections.
225# Some quick tests about JPEG image:
226# median size of 50 base64-encoded JPEG random RGB image
227# 128x128 pixels: 14kb ± 0.04
228# 256x256 pixels: 53kb ± 0.06 # sounds like a good tradeoff
229# 384x384 pixels: 119kb ± 0.11
230# 512x512 pixels: 211kb ± 0.13
231# 640x640 pixels: 329kb ± 0.15
232# 768x768 pixels: 473kb ± 0.21
233# 896x896 pixels: 644kb ± 0.27
234# 1024x1024 pixels: 841kb ± 0.22
235MAX_LINK_PREVIEW_IMAGE_SIZE = 256
237log = logging.getLogger(__name__)