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

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 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 AnyMessageReference, 

21 AnyMUC, 

22 ChatState, 

23 LegacyMessageType, 

24 LinkPreview, 

25 ProcessingHint, 

26) 

27from .. import config 

28from .base import BaseSender 

29 

30if TYPE_CHECKING: 

31 from ...group import LegacyParticipant 

32 

33 

34class MessageMaker(BaseSender): 

35 mtype: MessageTypes = NotImplemented 

36 _can_send_carbon: bool = NotImplemented 

37 STRIP_SHORT_DELAY = False 

38 USE_STANZA_ID = False 

39 

40 muc: AnyMUC 

41 

42 def _recipient_pk(self) -> int: 

43 return ( 

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

45 ) 

46 

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 

101 

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 

114 

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

126 

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) 

137 

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 

141 

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

143 

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 

179 

180 if fallback := reply_to.body: 

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

182 

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) 

200 

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 

210 

211 rewrite = False 

212 if image.format != "JPEG": 

213 rewrite = True 

214 

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 

218 

219 if rewrite: 

220 with io.BytesIO() as f: 

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

222 data = f.getvalue() 

223 

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

225 

226 

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 

242 

243log = logging.getLogger(__name__)