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

105 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-28 18:29 +0000

1import logging 

2import uuid 

3import warnings 

4 

5from slixmpp import Iq, Message 

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

7 

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

9from .attachment import AttachmentMixin 

10from .message_maker import MessageMaker 

11from .message_text import TextMessageMixin 

12 

13# this is for MDS 

14PUBLISH_OPTIONS = Form() 

15PUBLISH_OPTIONS["type"] = "submit" 

16PUBLISH_OPTIONS.add_field( 

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

18) 

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

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

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

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

23 

24 

25class ChatStateMixin(MessageMaker): 

26 def __init__(self) -> None: 

27 super().__init__() 

28 self.__last_chat_state: ChatState | None = None 

29 

30 def _chat_state( 

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

32 ) -> None: 

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

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

35 return 

36 self.__last_chat_state = state 

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

38 self._send(msg, **kwargs) 

39 

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

41 """ 

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

43 :term:`XMPP Entity`. 

44 """ 

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

46 

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

48 """ 

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

50 from this :term:`XMPP Entity`. 

51 """ 

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

53 

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

55 """ 

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

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

58 """ 

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

60 

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

62 """ 

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

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

65 from this :term:`XMPP Entity`. 

66 """ 

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

68 

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

70 """ 

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

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

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

74 """ 

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

76 

77 

78class MarkerMixin(MessageMaker): 

79 def _make_marker( 

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

81 ) -> Message: 

82 msg = self._make_message(carbon=carbon) 

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

84 return msg 

85 

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

87 """ 

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

89 

90 :param legacy_msg_id: The message this marker refers to 

91 """ 

92 self._send( 

93 self._make_marker( 

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

95 ), 

96 **kwargs, 

97 ) 

98 

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

100 """ 

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

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

103 marker (:xep:`0184`). 

104 

105 :param legacy_msg_id: The message this marker refers to 

106 """ 

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

108 if self.mtype == "chat": 

109 for msg_id in self._legacy_to_xmpp(legacy_msg_id): 

110 self._send( 

111 self.xmpp.delivery_receipt.make_ack( 

112 msg_id, 

113 mfrom=self.jid, 

114 mto=self.user_jid, 

115 ) 

116 ) 

117 self._send( 

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

119 ) 

120 

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

122 """ 

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

124 

125 :param legacy_msg_id: The message this marker refers to 

126 """ 

127 if ( 

128 self.xmpp.MARK_ALL_MESSAGES 

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

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

131 ): 

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

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

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

135 ): 

136 self.session.log.debug( 

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

138 ) 

139 return 

140 else: 

141 muc.pop_unread_xmpp_ids_up_to( 

142 self._legacy_to_xmpp(legacy_msg_id)[-1] 

143 ) 

144 

145 self._send( 

146 self._make_marker( 

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

148 ), 

149 **kwargs, 

150 ) 

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

152 self.session.create_task(self.__send_mds(legacy_msg_id)) 

153 

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

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

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

157 muc_jid = muc.jid.bare 

158 else: 

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

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

161 # ATM. 

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

163 # so it should not be an issue. 

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

165 return 

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

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

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

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

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

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

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

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

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

175 try: 

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

177 except Exception as e: 

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

179 

180 

181class ContentMessageMixin(AttachmentMixin, TextMessageMixin): 

182 pass 

183 

184 

185class CarbonMessageMixin(ContentMessageMixin, MarkerMixin): 

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

187 i = msg.get_id() 

188 if i: 

189 self.session.ignore_messages.add(i) 

190 else: 

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

192 msg.set_id(i) 

193 msg.del_origin_id() 

194 try: 

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

196 except PermissionError: 

197 warnings.warn( 

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

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

200 ) 

201 

202 

203class InviteMixin(MessageMaker): 

204 def invite_to( 

205 self, 

206 muc: AnyMUC, 

207 reason: str | None = None, 

208 password: str | None = None, 

209 **send_kwargs: object, 

210 ) -> None: 

211 """ 

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

213 

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

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

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

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

218 (internal use by slidge) 

219 """ 

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

221 msg["groupchat_invite"]["jid"] = muc.jid 

222 if reason: 

223 msg["groupchat_invite"]["reason"] = reason 

224 if password: 

225 msg["groupchat_invite"]["password"] = password 

226 self._send(msg, **send_kwargs) 

227 

228 

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

230 pass 

231 

232 

233class MessageCarbonMixin(InviteMixin, ChatStateMixin, CarbonMessageMixin): 

234 pass 

235 

236 

237log = logging.getLogger(__name__)