Coverage for slidge / core / mixins / message_maker.py: 89%
139 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-03-13 22:59 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-03-13 22:59 +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 TYPE_CHECKING, cast
11from PIL import Image
12from slixmpp import JID, 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 ChatState,
21 LegacyMessageType,
22 LinkPreview,
23 MessageReference,
24 ProcessingHint,
25)
26from .. import config
27from .base import BaseSender
29if TYPE_CHECKING:
30 from ...group import LegacyMUC, LegacyParticipant
33class MessageMaker(BaseSender):
34 mtype: MessageTypes = NotImplemented
35 _can_send_carbon: bool = NotImplemented
36 STRIP_SHORT_DELAY = False
37 USE_STANZA_ID = False
39 muc: "LegacyMUC"
41 def _recipient_pk(self) -> int:
42 return (
43 self.muc.stored.id if self.is_participant else self.stored.id # type:ignore
44 )
46 def _make_message(
47 self,
48 state: ChatState | None = None,
49 hints: Iterable[ProcessingHint] = (),
50 legacy_msg_id: LegacyMessageType | None = None,
51 when: datetime | None = None,
52 reply_to: MessageReference | None = None,
53 carbon: bool = False,
54 link_previews: Iterable[LinkPreview] | None = None,
55 **kwargs,
56 ):
57 body = kwargs.pop("mbody", None)
58 mfrom = kwargs.pop("mfrom", self.jid)
59 mto = kwargs.pop("mto", None)
60 thread = kwargs.pop("thread", None)
61 if carbon and self._can_send_carbon:
62 # the msg needs to have jabber:client as xmlns, so
63 # we don't want to associate with the XML stream
64 msg_cls = Message
65 else:
66 msg_cls = self.xmpp.Message # type:ignore
67 msg = msg_cls(
68 sfrom=mfrom,
69 stype=kwargs.pop("mtype", None) or self.mtype,
70 sto=mto,
71 **kwargs,
72 )
73 if body:
74 msg["body"] = strip_illegal_chars(body, "�")
75 state = "active"
76 if thread:
77 with self.xmpp.store.session() as orm:
78 thread_str = str(thread)
79 msg["thread"] = (
80 self.xmpp.store.id_map.get_thread(
81 orm,
82 self._recipient_pk(),
83 thread_str,
84 self.is_participant,
85 )
86 or thread_str
87 )
88 if state:
89 msg["chat_state"] = state
90 for hint in hints:
91 msg.enable(hint)
92 self._set_msg_id(msg, legacy_msg_id)
93 self._add_delay(msg, when)
94 if link_previews:
95 self._add_link_previews(msg, link_previews)
96 if reply_to:
97 self._add_reply_to(msg, reply_to)
98 return msg
100 def _set_msg_id(
101 self, msg: Message, legacy_msg_id: LegacyMessageType | None = None
102 ) -> None:
103 if legacy_msg_id is not None:
104 i = self.session.legacy_to_xmpp_msg_id(legacy_msg_id)
105 msg.set_id(i)
106 if self.USE_STANZA_ID:
107 msg["stanza_id"]["id"] = i
108 msg["stanza_id"]["by"] = self.muc.jid
109 elif self.USE_STANZA_ID:
110 msg["stanza_id"]["id"] = str(uuid.uuid4())
111 msg["stanza_id"]["by"] = self.muc.jid
113 def _legacy_to_xmpp(self, legacy_id: LegacyMessageType) -> list[str]:
114 with self.xmpp.store.session() as orm:
115 ids = self.xmpp.store.id_map.get_xmpp(
116 orm,
117 self._recipient_pk(),
118 str(legacy_id),
119 self.is_participant,
120 )
121 if ids:
122 return ids
123 return [self.session.legacy_to_xmpp_msg_id(legacy_id)]
125 def _add_delay(self, msg: Message, when: datetime | None) -> None:
126 if when:
127 if when.tzinfo is None:
128 when = when.astimezone(UTC)
129 if 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 jid = JID(muc.jid)
153 jid.resource = fallback_nick = muc.user_nick
154 msg["reply"]["to"] = jid
155 else:
156 msg["reply"]["to"] = self.session.user_jid
157 # TODO: here we should use preferably use the PEP nick of the user
158 # (but it doesn't matter much)
159 fallback_nick = self.session.user_jid.user
160 else:
161 if muc:
162 if hasattr(entity, "muc"):
163 # TODO: accept a Contact here and use muc.get_participant_by_legacy_id()
164 # a bit of work because right now this is a sync function
165 entity = cast("LegacyParticipant", entity)
166 fallback_nick = entity.nickname
167 else:
168 warnings.warn(
169 "The author of a message reference in a MUC must be a"
170 " Participant instance, not a Contact"
171 )
172 fallback_nick = entity.name
173 else:
174 fallback_nick = entity.name
175 msg["reply"]["to"] = entity.jid
176 else:
177 fallback_nick = None
179 if fallback := reply_to.body:
180 msg["reply"].add_quoted_fallback(fallback, fallback_nick)
182 def _add_link_previews(
183 self, msg: Message, link_previews: Iterable[LinkPreview]
184 ) -> None:
185 for preview in link_previews:
186 if preview.is_empty:
187 continue
188 element = LinkMetadata()
189 for i, name in enumerate(preview._fields):
190 val = preview[i]
191 if isinstance(val, Path):
192 val = val.read_bytes()
193 if isinstance(val, bytes):
194 val = self._process_link_preview_image(val)
195 if not val:
196 continue
197 element[name] = val
198 msg.append(element)
200 @staticmethod
201 def _process_link_preview_image(data: bytes) -> str | None:
202 # this will block the main thread. if this proves to be an issue in practice,
203 # this could be rewritten to use the thread pool we use to resize avatars.
204 try:
205 image = Image.open(io.BytesIO(data))
206 except Exception:
207 log.exception("Skipping link preview image")
208 return None
210 rewrite = False
211 if image.format != "JPEG":
212 rewrite = True
214 if any(x > MAX_LINK_PREVIEW_IMAGE_SIZE for x in image.size):
215 image.thumbnail((MAX_LINK_PREVIEW_IMAGE_SIZE, MAX_LINK_PREVIEW_IMAGE_SIZE))
216 rewrite = True
218 if rewrite:
219 with io.BytesIO() as f:
220 image.save(f, format="JPEG")
221 data = f.getvalue()
223 return "data:image/jpeg;base64," + base64.b64encode(data).decode("utf-8")
226# Instead of having a hardcoded value for this, we would ideally use
227# XEP-0478: Stream Limits Advertisement to know which size is authorized.
228# However, this isn't possible until XEP-0225: Component Connections is a thing.
229# Prosody defaults to 512kb for s2s connection and 10Mb for c2s connections.
230# Some quick tests about JPEG image:
231# median size of 50 base64-encoded JPEG random RGB image
232# 128x128 pixels: 14kb ± 0.04
233# 256x256 pixels: 53kb ± 0.06 # sounds like a good tradeoff
234# 384x384 pixels: 119kb ± 0.11
235# 512x512 pixels: 211kb ± 0.13
236# 640x640 pixels: 329kb ± 0.15
237# 768x768 pixels: 473kb ± 0.21
238# 896x896 pixels: 644kb ± 0.27
239# 1024x1024 pixels: 841kb ± 0.22
240MAX_LINK_PREVIEW_IMAGE_SIZE = 256
242log = logging.getLogger(__name__)