Coverage for slidge/core/mixins/message.py: 87%
106 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-18 04:30 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-18 04:30 +0000
1import logging
2import uuid
3import warnings
5from slixmpp import Iq, Message
6from slixmpp.plugins.xep_0004.stanza.form import Form
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
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")
26class ChatStateMixin(MessageMaker):
27 def __init__(self) -> None:
28 super().__init__()
29 self.__last_chat_state: ChatState | None = None
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)
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)
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)
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)
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)
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)
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
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`.
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 )
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`).
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 )
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`.
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 )
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(self.__send_mds(legacy_msg_id))
155 async def __send_mds(self, legacy_msg_id: str) -> None:
156 # Send a MDS displayed marker on behalf of the user for a group chat
157 if muc := getattr(self, "muc", None):
158 muc_jid = muc.jid.bare
159 else:
160 # This is not implemented for 1:1 chat because it would rely on
161 # storing the XMPP-server injected stanza-id, which we don't track
162 # ATM.
163 # In practice, MDS should mostly be useful for public group chats,
164 # so it should not be an issue.
165 # We'll see if we need to implement that later
166 return
167 xmpp_msg_id = self._legacy_to_xmpp(legacy_msg_id)[-1]
168 iq = Iq(sto=self.user_jid.bare, sfrom=self.user_jid.bare, stype="set")
169 iq["pubsub"]["publish"]["node"] = self.xmpp.plugin["xep_0490"].stanza.NS
170 iq["pubsub"]["publish"]["item"]["id"] = muc_jid
171 displayed = self.xmpp.plugin["xep_0490"].stanza.Displayed()
172 displayed["stanza_id"]["id"] = xmpp_msg_id
173 displayed["stanza_id"]["by"] = muc_jid
174 iq["pubsub"]["publish"]["item"]["payload"] = displayed
175 iq["pubsub"]["publish_options"] = PUBLISH_OPTIONS
176 try:
177 await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
178 except Exception as e:
179 self.session.log.debug("Could not MDS mark", exc_info=e)
182class ContentMessageMixin(AttachmentMixin, TextMessageMixin):
183 pass
186class CarbonMessageMixin(ContentMessageMixin, MarkerMixin):
187 def _privileged_send(self, msg: Message) -> None:
188 i = msg.get_id()
189 if i:
190 self.session.ignore_messages.add(i)
191 else:
192 i = "slidge-carbon-" + str(uuid.uuid4())
193 msg.set_id(i)
194 msg.del_origin_id()
195 try:
196 self.xmpp.plugin["xep_0356"].send_privileged_message(msg)
197 except PermissionError:
198 warnings.warn(
199 f"Slidge does not have the privilege (XEP-0356) to send messages on behalf of {self.user_jid}. "
200 "If this is a local user, consider configuring your XMPP server for that."
201 )
204class InviteMixin(MessageMaker):
205 def invite_to(
206 self,
207 muc: AnyMUC,
208 reason: str | None = None,
209 password: str | None = None,
210 **send_kwargs: object,
211 ) -> None:
212 """
213 Send an invitation to join a group (:xep:`0249`) from this :term:`XMPP Entity`.
215 :param muc: the muc the user is invited to
216 :param reason: a text explaining why the user should join this muc
217 :param password: maybe this will make sense later? not sure
218 :param send_kwargs: additional kwargs to be passed to _send()
219 (internal use by slidge)
220 """
221 msg = self._make_message(mtype="normal")
222 msg["groupchat_invite"]["jid"] = muc.jid
223 if reason:
224 msg["groupchat_invite"]["reason"] = reason
225 if password:
226 msg["groupchat_invite"]["password"] = password
227 self._send(msg, **send_kwargs)
230class MessageMixin(InviteMixin, ChatStateMixin, MarkerMixin, ContentMessageMixin):
231 pass
234class MessageCarbonMixin(InviteMixin, ChatStateMixin, CarbonMessageMixin):
235 pass
238log = logging.getLogger(__name__)