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