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

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 

10 

11from PIL import Image 

12from slixmpp import Message 

13from slixmpp.plugins.xep_0511.stanza import LinkMetadata 

14from slixmpp.types import MessageTypes 

15 

16from slidge.util import strip_illegal_chars 

17 

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 

29 

30 

31class MessageMaker(BaseSender): 

32 mtype: MessageTypes = NotImplemented 

33 _can_send_carbon: bool = NotImplemented 

34 STRIP_SHORT_DELAY = False 

35 USE_STANZA_ID = False 

36 

37 muc: AnyMUC 

38 

39 def _recipient_pk(self) -> int | None: 

40 """Primary key of receiver of messages made.""" 

41 return None 

42 

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 

95 

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 

105 

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] 

120 

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) 

131 

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 

135 

136 muc = getattr(self, "muc", None) 

137 

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 

173 

174 if fallback := reply_to.body: 

175 msg["reply"].add_quoted_fallback(fallback, fallback_nick) 

176 

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) 

194 

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 

204 

205 rewrite = False 

206 if image.format != "JPEG": 

207 rewrite = True 

208 

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 

212 

213 if rewrite: 

214 with io.BytesIO() as f: 

215 image.save(f, format="JPEG") 

216 data = f.getvalue() 

217 

218 return "data:image/jpeg;base64," + base64.b64encode(data).decode("utf-8") 

219 

220 

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 

236 

237log = logging.getLogger(__name__)