Coverage for slidge/core/mixins/message.py: 84%

113 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-10 04:45 +0000

1import logging 

2import uuid 

3import warnings 

4 

5from slixmpp import JID, Iq, Message 

6from slixmpp.plugins.xep_0004.stanza.form import Form 

7 

8from ...util.types import AnyMUC, ChatState, Marker 

9from .attachment import AttachmentMixin 

10from .base import SessionBound 

11from .message_maker import MessageMaker 

12from .message_text import TextMessageMixin 

13 

14# this is for MDS 

15PUBLISH_OPTIONS = Form() 

16PUBLISH_OPTIONS["type"] = "submit" 

17PUBLISH_OPTIONS.add_field( 

18 "FORM_TYPE", "hidden", value="http://jabber.org/protocol/pubsub#publish-options" 

19) 

20PUBLISH_OPTIONS.add_field("pubsub#persist_items", value="true") 

21PUBLISH_OPTIONS.add_field("pubsub#max_items", value="max") 

22PUBLISH_OPTIONS.add_field("pubsub#send_last_published_item", value="never") 

23PUBLISH_OPTIONS.add_field("pubsub#access_model", value="whitelist") 

24 

25 

26class ChatStateMixin(MessageMaker): 

27 def __init__(self) -> None: 

28 super().__init__() 

29 self.__last_chat_state: ChatState | None = None 

30 

31 def _chat_state( 

32 self, state: ChatState, forced: bool = False, **kwargs: object 

33 ) -> None: 

34 carbon = kwargs.get("carbon", False) 

35 if carbon or (state == self.__last_chat_state and not forced): 

36 return 

37 self.__last_chat_state = state 

38 msg = self._make_message(state=state, hints={"no-store"}) 

39 self._send(msg, **kwargs) 

40 

41 def active(self, **kwargs: object) -> None: 

42 """ 

43 Send an "active" chat state (:xep:`0085`) from this 

44 :term:`XMPP Entity`. 

45 """ 

46 self._chat_state("active", forced=False, **kwargs) 

47 

48 def composing(self, **kwargs: object) -> None: 

49 """ 

50 Send a "composing" (ie "typing notification") chat state (:xep:`0085`) 

51 from this :term:`XMPP Entity`. 

52 """ 

53 self._chat_state("composing", forced=True, **kwargs) 

54 

55 def paused(self, **kwargs: object) -> None: 

56 """ 

57 Send a "paused" (ie "typing paused notification") chat state 

58 (:xep:`0085`) from this :term:`XMPP Entity`. 

59 """ 

60 self._chat_state("paused", forced=False, **kwargs) 

61 

62 def inactive(self, **kwargs: object) -> None: 

63 """ 

64 Send an "inactive" (ie "contact has not interacted with the chat session 

65 interface for an intermediate period of time") chat state (:xep:`0085`) 

66 from this :term:`XMPP Entity`. 

67 """ 

68 self._chat_state("inactive", forced=False, **kwargs) 

69 

70 def gone(self, **kwargs: object) -> None: 

71 """ 

72 Send a "gone" (ie "contact has not interacted with the chat session interface, 

73 system, or device for a relatively long period of time") chat state 

74 (:xep:`0085`) from this :term:`XMPP Entity`. 

75 """ 

76 self._chat_state("gone", forced=False, **kwargs) 

77 

78 

79class MarkerMixin(MessageMaker, SessionBound): 

80 def _make_marker( 

81 self, legacy_msg_id: str, marker: Marker, carbon: bool = False 

82 ) -> Message: 

83 msg = self._make_message(carbon=carbon) 

84 msg[marker]["id"] = self._legacy_to_xmpp(legacy_msg_id)[-1] 

85 return msg 

86 

87 def ack(self, legacy_msg_id: str, **kwargs: object) -> None: 

88 """ 

89 Send an "acknowledged" message marker (:xep:`0333`) from this :term:`XMPP Entity`. 

90 

91 :param legacy_msg_id: The message this marker refers to 

92 """ 

93 self._send( 

94 self._make_marker( 

95 legacy_msg_id, "acknowledged", carbon=bool(kwargs.get("carbon")) 

96 ), 

97 **kwargs, 

98 ) 

99 

100 def received(self, legacy_msg_id: str, **kwargs: object) -> None: 

101 """ 

102 Send a "received" message marker (:xep:`0333`) from this :term:`XMPP Entity`. 

103 If called on a :class:`LegacyContact`, also send a delivery receipt 

104 marker (:xep:`0184`). 

105 

106 :param legacy_msg_id: The message this marker refers to 

107 """ 

108 carbon = bool(kwargs.get("carbon")) 

109 if self.mtype == "chat": 

110 for msg_id in self._legacy_to_xmpp(legacy_msg_id): 

111 self._send( 

112 self.xmpp.delivery_receipt.make_ack( 

113 msg_id, 

114 mfrom=self.jid, 

115 mto=self.user_jid, 

116 ) 

117 ) 

118 self._send( 

119 self._make_marker(legacy_msg_id, "received", carbon=carbon), **kwargs 

120 ) 

121 

122 def displayed(self, legacy_msg_id: str, **kwargs: object) -> None: 

123 """ 

124 Send a "displayed" message marker (:xep:`0333`) from this :term:`XMPP Entity`. 

125 

126 :param legacy_msg_id: The message this marker refers to 

127 """ 

128 if ( 

129 self.xmpp.MARK_ALL_MESSAGES 

130 and (muc := getattr(self, "muc", None)) 

131 and getattr(self, "is_user", False) 

132 ): 

133 with self.xmpp.store.session() as orm: 

134 if self.xmpp.store.mam.is_displayed_by_user( 

135 orm, muc.jid.local, str(legacy_msg_id) 

136 ): 

137 self.session.log.debug( 

138 "Ignoring carbon marker for message already displayed by user." 

139 ) 

140 return 

141 else: 

142 muc.pop_unread_xmpp_ids_up_to( 

143 self._legacy_to_xmpp(legacy_msg_id)[-1] 

144 ) 

145 

146 self._send( 

147 self._make_marker( 

148 legacy_msg_id, "displayed", carbon=bool(kwargs.get("carbon")) 

149 ), 

150 **kwargs, 

151 ) 

152 if getattr(self, "is_user", False): 

153 self.session.create_task( 

154 self.__send_mds(legacy_msg_id), 

155 name=f"send MDS {legacy_msg_id!r} for {self}", 

156 ) 

157 

158 async def __send_mds(self, legacy_msg_id: str) -> None: 

159 # Send a MDS displayed marker on behalf of the user for a group chat 

160 if muc := getattr(self, "muc", None): 

161 muc_jid = muc.jid.bare 

162 else: 

163 # This is not implemented for 1:1 chat because it would rely on 

164 # storing the XMPP-server injected stanza-id, which we don't track 

165 # ATM. 

166 # In practice, MDS should mostly be useful for public group chats, 

167 # so it should not be an issue. 

168 # We'll see if we need to implement that later 

169 return 

170 xmpp_msg_id = self._legacy_to_xmpp(legacy_msg_id)[-1] 

171 iq = Iq(sto=self.user_jid.bare, sfrom=self.user_jid.bare, stype="set") 

172 iq["pubsub"]["publish"]["node"] = self.xmpp.plugin["xep_0490"].stanza.NS 

173 iq["pubsub"]["publish"]["item"]["id"] = muc_jid 

174 displayed = self.xmpp.plugin["xep_0490"].stanza.Displayed() 

175 displayed["stanza_id"]["id"] = xmpp_msg_id 

176 displayed["stanza_id"]["by"] = muc_jid 

177 iq["pubsub"]["publish"]["item"]["payload"] = displayed 

178 iq["pubsub"]["publish_options"] = PUBLISH_OPTIONS 

179 try: 

180 await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq) 

181 except Exception as e: 

182 self.session.log.debug("Could not MDS mark", exc_info=e) 

183 

184 

185class ContentMessageMixin(AttachmentMixin, TextMessageMixin): 

186 pass 

187 

188 

189class CarbonMessageMixin(ContentMessageMixin, MarkerMixin): 

190 def _privileged_send(self, msg: Message) -> None: 

191 i = msg.get_id() 

192 if i: 

193 self.session.ignore_messages.add(i) 

194 else: 

195 i = "slidge-carbon-" + str(uuid.uuid4()) 

196 msg.set_id(i) 

197 msg.del_origin_id() 

198 try: 

199 self.xmpp.plugin["xep_0356"].send_privileged_message(msg) 

200 except PermissionError: 

201 warnings.warn( 

202 f"Slidge does not have the privilege (XEP-0356) to send messages on behalf of {self.user_jid}. " 

203 "If this is a local user, consider configuring your XMPP server for that." 

204 ) 

205 

206 

207class InviteMixin(MessageMaker): 

208 def invite_to( 

209 self, 

210 muc: AnyMUC | JID | str, 

211 reason: str | None = None, 

212 password: str | None = None, 

213 **send_kwargs: object, 

214 ) -> None: 

215 """ 

216 Send an invitation to join a group (:xep:`0249`) from this :term:`XMPP Entity`. 

217 

218 :param muc: the muc the user is invited to 

219 :param reason: a text explaining why the user should join this muc 

220 :param password: maybe this will make sense later? not sure 

221 :param send_kwargs: additional kwargs to be passed to _send() 

222 (internal use by slidge) 

223 """ 

224 msg = self._make_message(mtype="normal") 

225 jid = muc if isinstance(muc, str | JID) else muc.jid 

226 msg["groupchat_invite"]["jid"] = jid 

227 if reason: 

228 msg["groupchat_invite"]["reason"] = reason 

229 if password: 

230 msg["groupchat_invite"]["password"] = password 

231 if self.session is None: 

232 self._send(msg, **send_kwargs) 

233 else: 

234 self.session.create_task( 

235 self.__invite_when_ready(msg, **send_kwargs), 

236 name=f"Invite from {self} to {jid}", 

237 ) 

238 

239 async def __invite_when_ready(self, msg: Message, **send_kwargs: object) -> None: 

240 assert self.session is not None 

241 await self.session.bookmarks.ready 

242 self._send(msg, **send_kwargs) 

243 

244 

245class MessageMixin(InviteMixin, ChatStateMixin, MarkerMixin, ContentMessageMixin): 

246 pass 

247 

248 

249class MessageCarbonMixin(InviteMixin, ChatStateMixin, CarbonMessageMixin): 

250 pass 

251 

252 

253log = logging.getLogger(__name__)