Coverage for slidge/core/mixins/message_maker.py: 86%
139 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-10 04:45 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-10 04:45 +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 # .contacts (Roster) not being ready means we are in Contact.backfill()
126 # and in this situation we never want to strip the delay.
127 if (
128 self.session and self.session.contacts.ready.done()
129 ) and self.STRIP_SHORT_DELAY:
130 delay = (datetime.now().astimezone(UTC) - when).seconds
131 if delay < config.IGNORE_DELAY_THRESHOLD:
132 return
133 msg["delay"].set_stamp(when)
134 msg["delay"].set_from(self.xmpp.boundjid.bare)
136 def _add_reply_to(self, msg: Message, reply_to: MessageReference) -> None:
137 xmpp_id = self._legacy_to_xmpp(reply_to.legacy_id)[0]
138 msg["reply"]["id"] = xmpp_id
140 muc = getattr(self, "muc", None)
142 if entity := reply_to.author:
143 if entity == "user" or isinstance(entity, GatewayUser):
144 if isinstance(entity, GatewayUser):
145 warnings.warn(
146 "Using a GatewayUser as the author of a "
147 "MessageReference is deprecated. Use the string 'user' "
148 "instead.",
149 DeprecationWarning,
150 )
151 if muc:
152 msg["reply"]["to"] = muc.user_muc_jid
153 fallback_nick = muc.user_nick
154 elif (session := self.session) is not None:
155 msg["reply"]["to"] = session.user_jid
156 # TODO: here we should use preferably use the PEP nick of the user
157 # (but it doesn't matter much)
158 fallback_nick = session.user_jid.user
159 else:
160 if muc:
161 if hasattr(entity, "muc"):
162 # TODO: accept a Contact here and use muc.get_participant_by_legacy_id()
163 # a bit of work because right now this is a sync function
164 entity = cast(AnyParticipant, entity)
165 fallback_nick = entity.nickname
166 else:
167 warnings.warn(
168 "The author of a message reference in a MUC must be a"
169 " Participant instance, not a Contact"
170 )
171 fallback_nick = entity.name
172 else:
173 fallback_nick = entity.name
174 msg["reply"]["to"] = entity.jid
175 else:
176 fallback_nick = None
178 if fallback := reply_to.body:
179 msg["reply"].add_quoted_fallback(fallback, fallback_nick)
181 def _add_link_previews(
182 self, msg: Message, link_previews: Iterable[LinkPreview]
183 ) -> None:
184 for preview in link_previews:
185 if preview.is_empty:
186 continue
187 element = LinkMetadata()
188 for i, name in enumerate(preview._fields):
189 val = preview[i]
190 if isinstance(val, Path):
191 val = val.read_bytes()
192 if isinstance(val, bytes):
193 val = self._process_link_preview_image(val)
194 if not val:
195 continue
196 element[name] = val
197 msg.append(element)
199 @staticmethod
200 def _process_link_preview_image(data: bytes) -> str | None:
201 # this will block the main thread. if this proves to be an issue in practice,
202 # this could be rewritten to use the thread pool we use to resize avatars.
203 try:
204 image = Image.open(io.BytesIO(data))
205 except Exception:
206 log.exception("Skipping link preview image")
207 return None
209 rewrite = False
210 if image.format != "JPEG":
211 rewrite = True
213 if any(x > MAX_LINK_PREVIEW_IMAGE_SIZE for x in image.size):
214 image.thumbnail((MAX_LINK_PREVIEW_IMAGE_SIZE, MAX_LINK_PREVIEW_IMAGE_SIZE))
215 rewrite = True
217 if rewrite:
218 with io.BytesIO() as f:
219 image.save(f, format="JPEG")
220 data = f.getvalue()
222 return "data:image/jpeg;base64," + base64.b64encode(data).decode("utf-8")
225# Instead of having a hardcoded value for this, we would ideally use
226# XEP-0478: Stream Limits Advertisement to know which size is authorized.
227# However, this isn't possible until XEP-0225: Component Connections is a thing.
228# Prosody defaults to 512kb for s2s connection and 10Mb for c2s connections.
229# Some quick tests about JPEG image:
230# median size of 50 base64-encoded JPEG random RGB image
231# 128x128 pixels: 14kb ± 0.04
232# 256x256 pixels: 53kb ± 0.06 # sounds like a good tradeoff
233# 384x384 pixels: 119kb ± 0.11
234# 512x512 pixels: 211kb ± 0.13
235# 640x640 pixels: 329kb ± 0.15
236# 768x768 pixels: 473kb ± 0.21
237# 896x896 pixels: 644kb ± 0.27
238# 1024x1024 pixels: 841kb ± 0.22
239MAX_LINK_PREVIEW_IMAGE_SIZE = 256
241log = logging.getLogger(__name__)