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

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 

10 

11from PIL import Image 

12from slixmpp import JID, 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 ChatState, 

21 LegacyMessageType, 

22 LinkPreview, 

23 MessageReference, 

24 ProcessingHint, 

25) 

26from .. import config 

27from .base import BaseSender 

28 

29if TYPE_CHECKING: 

30 from ...group import LegacyMUC, LegacyParticipant 

31 

32 

33class MessageMaker(BaseSender): 

34 mtype: MessageTypes = NotImplemented 

35 _can_send_carbon: bool = NotImplemented 

36 STRIP_SHORT_DELAY = False 

37 USE_STANZA_ID = False 

38 

39 muc: "LegacyMUC" 

40 

41 def _recipient_pk(self) -> int: 

42 return ( 

43 self.muc.stored.id if self.is_participant else self.stored.id # type:ignore 

44 ) 

45 

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 

99 

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 

112 

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)] 

124 

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) 

135 

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 

139 

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

141 

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 

178 

179 if fallback := reply_to.body: 

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

181 

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) 

199 

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 

209 

210 rewrite = False 

211 if image.format != "JPEG": 

212 rewrite = True 

213 

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 

217 

218 if rewrite: 

219 with io.BytesIO() as f: 

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

221 data = f.getvalue() 

222 

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

224 

225 

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 

241 

242log = logging.getLogger(__name__)