Coverage for slidge/room/participant.py: 93%
302 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-10 12:26 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-10 12:26 +0000
1import logging
2import warnings
3from copy import copy
4from datetime import datetime
5from typing import TYPE_CHECKING, Any, Literal
6from xml.etree import ElementTree as ET
8from slixmpp import JID, Iq, Message, Presence
9from slixmpp.plugins.xep_0004.stanza.form import Form
10from slixmpp.plugins.xep_0030.stanza.info import DiscoInfo
11from slixmpp.plugins.xep_0045.stanza import MUCAdminItem
12from slixmpp.plugins.xep_0492.stanza import Never
13from slixmpp.types import MessageTypes, OptJid, PresenceShows
14from typing_extensions import TypedDict
16from ..core.mixins import ChatterDiscoMixin, MessageMixin, PresenceMixin
17from ..core.mixins.db import UpdateInfoMixin
18from ..db.models import Participant
19from ..util.types import (
20 AnyRoom,
21 ExtraAttrMapping,
22 Hat,
23 MessageOrPresenceTypeVar,
24 MucAffiliation,
25 MucRole,
26)
28if TYPE_CHECKING:
29 from slidge.command.base import ContactCommand
30 from slidge.contact import LegacyContact
33class _ParticipantProps(TypedDict, total=False):
34 affiliation: MucAffiliation
35 role: MucRole
36 hats: list[Hat]
37 nickname: str
38 pshow: PresenceShows | Literal["available"] | None
39 pstatus: str | None
40 last_seen: datetime | None
43class LegacyParticipant[LegacyContactType: "LegacyContact", T: ExtraAttrMapping = Any](
44 PresenceMixin,
45 MessageMixin,
46 ChatterDiscoMixin,
47 UpdateInfoMixin[T, _ParticipantProps],
48):
49 """
50 A legacy participant of a legacy group chat.
51 """
53 is_participant: Literal[True] = True
55 _mtype: MessageTypes = "groupchat"
56 _can_send_carbon = False
57 _USE_STANZA_ID = True
58 _STRIP_SHORT_DELAY = False
59 _stored: Participant
60 contact: LegacyContactType | None
62 def __init__(
63 self,
64 muc: AnyRoom,
65 stored: Participant,
66 is_system: bool = False,
67 contact: LegacyContactType | None = None,
68 ) -> None:
69 self._room = muc
70 self._session = muc._session
71 self.xmpp = muc._session.xmpp
72 self.is_system = is_system
74 if contact is None:
75 stored.ensure_relation("contact", self.xmpp.store.session)
76 if stored.contact is not None:
77 contact = self._session.contacts._from_store(stored.contact)
79 self._stored = stored
80 self.contact = contact
82 if contact is not None:
83 self._stored._set_committed_value("contact", contact._stored)
85 super().__init__()
86 self.log = logging.getLogger(f"{self.user_jid.bare}:{self.jid}")
88 def _recipient_pk(self) -> int:
89 return self._room._stored.id
91 def __eq__(self, other: object) -> bool:
92 return isinstance(other, LegacyParticipant) and self.jid == other.jid
94 @property
95 def is_user(self) -> bool:
96 return bool(self._stored.is_user)
98 @is_user.setter
99 def is_user(self, is_user: bool) -> None:
100 self._update_stored_attribute(is_user=is_user)
102 @property
103 def jid(self) -> JID:
104 jid = JID(self._room.jid)
105 if self._stored.resource:
106 jid.resource = self._stored.resource
107 return jid
109 @property
110 def _commands(self) -> dict[str, "type[ContactCommand[Any]]"]: # type:ignore[override]
111 if self.contact is None:
112 return {}
113 else:
114 return self.contact._commands
116 def __repr__(self) -> str:
117 return f"<Participant '{self.nickname}'/'{self.jid}' of '{self._room}'>"
119 @property
120 def affiliation(self) -> MucAffiliation:
121 return self._stored.affiliation
123 @affiliation.setter
124 def affiliation(self, affiliation: MucAffiliation) -> None:
125 was = self.affiliation
126 if not self._update_stored_attribute(affiliation=affiliation):
127 return
128 if not self._room.participants_filled:
129 return
130 if self.contact and not self.contact.online:
131 self._room._stored.update_participant(self._stored)
132 self._room.send_affiliation_change(self, was)
133 self.send_last_presence(force=True)
135 @property
136 def role(self) -> MucRole:
137 return self._stored.role
139 @role.setter
140 def role(self, role: MucRole) -> None:
141 if not self._update_stored_attribute(role=role):
142 return
143 if not self._room.participants_filled:
144 return
145 self.send_last_presence(force=True)
147 @property
148 def hats(self) -> list[Hat]:
149 return [Hat(*h) for h in self._stored.hats] if self._stored.hats else []
151 def set_hats(self, hats: list[Hat]) -> None:
152 if not self._update_stored_attribute(hats=hats):
153 return
154 if not self._room.participants_filled:
155 return
157 self.send_last_presence(force=True)
159 def send_configuration_change(self, codes: tuple[int, ...]) -> None:
160 if not self.is_system:
161 raise RuntimeError("This is only possible for the system participant")
162 msg = self._make_message()
163 msg["muc"]["status_codes"] = codes
164 self._send(msg)
166 @property
167 def nickname(self) -> str:
168 return self._stored.nickname
170 @nickname.setter
171 def nickname(self, new_nickname: str) -> None:
172 old_jid = self.jid
173 if not self._update_stored_attribute(nickname=new_nickname):
174 return
176 self._room.participants._rename(self._stored, new_nickname, self.occupant_id)
177 p = self._make_presence(ptype="unavailable", status_codes={303})
178 p.set_from(old_jid)
179 p["muc"]["item"]["nick"] = self.jid.resource
180 self._send(p, force=True)
181 p = self._make_presence(ptype="available", last_seen=self.last_seen)
182 self._send(p, force=True)
184 def _make_presence( # type:ignore[no-untyped-def]
185 self,
186 *,
187 last_seen: datetime | None = None,
188 status_codes: set[int] | None = None,
189 user_full_jid: JID | None = None,
190 **presence_kwargs, # noqa
191 ) -> Presence:
192 p = super()._make_presence(last_seen=last_seen, **presence_kwargs)
193 p["muc"]["affiliation"] = self.affiliation
194 p["muc"]["role"] = self.role
195 if self.hats:
196 p["hats"].add_hats(self.hats)
197 codes = status_codes or set()
198 if self.is_user:
199 codes.add(110)
200 if not self._room.is_anonymous and not self.is_system:
201 if self.is_user:
202 if user_full_jid:
203 p["muc"]["jid"] = user_full_jid
204 else:
205 jid = JID(self.user_jid)
206 try:
207 jid.resource = next(iter(self._room.get_user_resources()))
208 except StopIteration:
209 jid.resource = "pseudo-resource"
210 p["muc"]["jid"] = self.user_jid
211 codes.add(100)
212 elif self.contact:
213 p["muc"]["jid"] = self.contact.jid
214 if a := self.contact.get_avatar():
215 p["vcard_temp_update"]["photo"] = a.id
216 if a.http_metadata is not None:
217 metadata = self.xmpp.plugin["xep_0084"].stanza.MetaData()
218 metadata.append(a.http_metadata)
219 p.append(metadata)
220 else:
221 warnings.warn(
222 f"Private group but no 1:1 JID associated to '{self}'",
223 )
224 if self.is_user and (hash_ := self._session.user.avatar_hash):
225 p["vcard_temp_update"]["photo"] = hash_
226 p["muc"]["status_codes"] = codes
227 return p
229 @property
230 def DISCO_NAME(self) -> str:
231 return self.nickname
233 @DISCO_NAME.setter
234 def DISCO_NAME(self, _: str) -> Never:
235 raise RuntimeError
237 @property
238 def occupant_id(self) -> str:
239 if self.is_user:
240 return "slidge-user"
241 elif self.contact:
242 return str(self.contact.jid)
243 else:
244 return self._stored.occupant_id or self.nickname
246 def _send(
247 self,
248 stanza: MessageOrPresenceTypeVar,
249 full_jid: JID | None = None,
250 legacy_msg_id: str | None = None,
251 force: bool = False,
252 **send_kwargs: Any, # noqa:ANN401
253 ) -> MessageOrPresenceTypeVar:
254 if stanza.get_from().resource:
255 stanza["occupant-id"]["id"] = self.occupant_id
256 else:
257 stanza["occupant-id"]["id"] = "room"
258 self.__add_nick_element(stanza)
259 if (
260 not self.is_user
261 and isinstance(stanza, Presence)
262 and stanza["type"] == "unavailable"
263 and not force
264 ):
265 return stanza
266 if full_jid:
267 stanza["to"] = full_jid
268 if self.is_user:
269 assert stanza.stream is not None
270 stanza.stream.send(stanza, use_filters=False)
271 else:
272 stanza.send()
273 else:
274 backfill = bool(self._room.get_lock("fill history"))
275 if isinstance(stanza, Message):
276 self._room.archive.add(stanza, self, backfill, legacy_msg_id)
277 if backfill:
278 return stanza
279 for user_full_jid in self._room.user_full_jids():
280 stanza = copy(stanza)
281 stanza["to"] = user_full_jid
282 stanza.send()
283 return stanza
285 def mucadmin_item(self) -> MUCAdminItem:
286 item = MUCAdminItem()
287 item["nick"] = self.nickname
288 item["affiliation"] = self.affiliation
289 item["role"] = self.role
290 if not self._room.is_anonymous:
291 if self.is_user:
292 item["jid"] = self.user_jid.bare
293 elif self.contact:
294 item["jid"] = self.contact.jid.bare
295 else:
296 warnings.warn(
297 (
298 f"Private group but no contact JID associated to {self.jid} in"
299 f" {self}"
300 ),
301 )
302 return item
304 def __add_nick_element(self, stanza: Presence | Message) -> None:
305 if self.is_system:
306 return
307 if (nick := self.nickname) != self.jid.resource:
308 n = self.xmpp.plugin["xep_0172"].stanza.UserNick()
309 n["nick"] = nick
310 stanza.append(n)
312 def send_initial_presence(
313 self,
314 full_jid: JID,
315 nick_change: bool = False,
316 presence_id: str | None = None,
317 mav_until: str | None = None,
318 ) -> None:
319 """
320 Called when the user joins a Room, as a mechanism
321 to indicate to the joining XMPP client the list of "participants".
323 Can be called this to trigger a "participant has joined the group" event.
325 :param full_jid: Set this to only send to a specific user XMPP resource.
326 :param nick_change: Used when the user joins and the Room renames them (code 210)
327 :param presence_id: set the presence ID. used internally by slidge
328 """
329 # Room status codes: https://xmpp.org/extensions/xep-0045.html#registrar-statuscodes
330 codes = set()
331 if nick_change:
332 codes.add(210)
334 if self.is_user:
335 # the "initial presence" of the user has to be vanilla, as it is
336 # a crucial part of the Room join sequence for XMPP clients.
337 kwargs = {}
338 else:
339 if self.contact:
340 self.contact._stored.ensure_loaded("presence", self.xmpp.store.session)
341 kwargs = {
342 "last_seen": self.contact._stored.last_seen,
343 "pstatus": self.contact._stored.pstatus,
344 "pshow": self.contact._stored.pshow,
345 }
346 else:
347 kwargs = {}
348 p = self._make_presence(
349 status_codes=codes,
350 user_full_jid=full_jid,
351 **kwargs, # type:ignore
352 )
353 if presence_id:
354 p["id"] = presence_id
355 if self.is_user and mav_until is not None:
356 p["muc"]["mav"]["until"] = mav_until
357 self._send(p, full_jid)
359 def leave(self) -> None:
360 """
361 Call this when the participant leaves the room
362 """
363 self._room.remove_participant(self)
365 def kick(self, reason: str | None = None) -> None:
366 """
367 Call this when the participant is kicked from the room
368 """
369 self._room.remove_participant(self, kick=True, reason=reason)
371 def ban(self, reason: str | None = None) -> None:
372 """
373 Call this when the participant is banned from the room
374 """
375 self._room.remove_participant(self, ban=True, reason=reason)
377 async def get_disco_info(
378 self, jid: OptJid = None, node: str | None = None
379 ) -> DiscoInfo:
380 if self.contact is not None:
381 return await self.contact.get_disco_info()
382 return await super().get_disco_info()
384 def moderate(self, legacy_msg_id: str, reason: str | None = None) -> None:
385 for i in self._legacy_to_xmpp(legacy_msg_id):
386 m = self._room.get_system_participant()._make_message()
387 m["retract"]["id"] = i
388 if self.is_system:
389 m["retract"].enable("moderated")
390 else:
391 m["retract"]["moderated"]["by"] = self.jid
392 m["retract"]["moderated"]["occupant-id"]["id"] = self.occupant_id
393 if reason:
394 m["retract"]["reason"] = reason
395 self._send(m)
397 def set_room_subject(
398 self,
399 subject: str,
400 full_jid: JID | None = None,
401 when: datetime | None = None,
402 update_muc: bool = True,
403 ) -> None:
404 if update_muc:
405 self._room.subject = subject
406 self._room.subject_setter = self.nickname
407 self._room.subject_date = when
409 msg = self._make_message()
410 if when is not None:
411 msg["delay"].set_stamp(when)
412 msg["delay"]["from"] = self._room.jid
413 if subject:
414 msg["subject"] = subject
415 else:
416 # may be simplified if slixmpp lets it do it more easily some day
417 msg.xml.append(ET.Element(f"{{{msg.namespace}}}subject"))
418 self._send(msg, full_jid)
420 def set_thread_subject(
421 self,
422 thread: str,
423 subject: str | None,
424 when: datetime | None = None,
425 ) -> None:
426 msg = self._make_message()
427 msg["thread"] = str(thread)
428 if when is not None:
429 msg["delay"].set_stamp(when)
430 msg["delay"]["from"] = self._room.jid
431 if subject:
432 msg["subject"] = subject
433 else:
434 # may be simplified if slixmpp lets it do it more easily some day
435 msg.xml.append(ET.Element(f"{{{msg.namespace}}}subject"))
436 self._send(msg)
438 async def on_set_affiliation(
439 self,
440 affiliation: MucAffiliation,
441 reason: str | None,
442 nickname: str | None,
443 ) -> None:
444 """
445 Triggered when the user requests changing the affiliation of a contact
446 for this group.
448 Examples: promotion them to moderator, ban (affiliation=outcast).
450 :param contact: The contact whose affiliation change is requested
451 :param affiliation: The new affiliation
452 :param reason: A reason for this affiliation change
453 :param nickname:
454 """
455 raise NotImplementedError
457 async def on_kick(self, reason: str | None) -> None:
458 """
459 Triggered when the user requests changing the role of a contact
460 to "none" for this group. Action commonly known as "kick".
462 :param contact: Contact to be kicked
463 :param reason: A reason for this kick
464 """
465 raise NotImplementedError
467 async def on_invitation(self, reason: str | None) -> None:
468 """
469 Triggered when the user invites this :term:`Contact <Legacy Contact>`
470 to a legacy Room via :xep:`0249`.
472 The default implementation calls :meth:`LegacyRoom.on_set_affiliation`
473 with the 'member' affiliation. Override if you want to customize this
474 behaviour.
476 :param muc: The group
477 :param reason: Optionally, a reason
478 """
479 # part = await self.muc.get_participant_by_contact(self)
480 await self.on_set_affiliation("member", reason, None)
482 def displayed(self, legacy_msg_id: str, **kwargs: object) -> None:
483 if self.xmpp.MARK_ALL_MESSAGES:
484 with self.xmpp.store.session() as orm:
485 if self.xmpp.store.mam.is_displayed_by_user(
486 orm, self._room.jid.local, str(legacy_msg_id)
487 ):
488 self._session.log.debug(
489 "Ignoring carbon marker for message already displayed by user."
490 )
491 return
492 else:
493 self._room.pop_unread_xmpp_ids_up_to(
494 self._legacy_to_xmpp(legacy_msg_id)[-1]
495 )
497 super().displayed(legacy_msg_id, **kwargs)
499 if not self.is_user:
500 return
501 self._session.create_task(
502 self.__send_mds(legacy_msg_id),
503 name=f"send MDS {legacy_msg_id!r} for {self}",
504 )
506 async def __send_mds(self, legacy_msg_id: str) -> None:
507 # Send a MDS displayed marker on behalf of the user for a group chat
508 # This is not implemented for 1:1 chat because it would rely on
509 # storing the XMPP-server injected stanza-id, which we don't track
510 # ATM.
511 # In practice, MDS should mostly be useful for public group chats,
512 # so it should not be an issue.
513 # We'll see if we need to implement that later
514 PUBLISH_OPTIONS = Form()
515 PUBLISH_OPTIONS["type"] = "submit"
516 PUBLISH_OPTIONS.add_field(
517 "FORM_TYPE",
518 "hidden",
519 value="http://jabber.org/protocol/pubsub#publish-options",
520 )
521 PUBLISH_OPTIONS.add_field("pubsub#persist_items", value="true")
522 PUBLISH_OPTIONS.add_field("pubsub#max_items", value="max")
523 PUBLISH_OPTIONS.add_field("pubsub#send_last_published_item", value="never")
524 PUBLISH_OPTIONS.add_field("pubsub#access_model", value="whitelist")
525 xmpp_msg_id = self._legacy_to_xmpp(legacy_msg_id)[-1]
526 muc_jid = self._room.jid
527 iq = Iq(sto=self.user_jid.bare, sfrom=self.user_jid.bare, stype="set")
528 iq["pubsub"]["publish"]["node"] = self.xmpp.plugin["xep_0490"].stanza.NS
529 iq["pubsub"]["publish"]["item"]["id"] = muc_jid
530 displayed = self.xmpp.plugin["xep_0490"].stanza.Displayed()
531 displayed["stanza_id"]["id"] = xmpp_msg_id
532 displayed["stanza_id"]["by"] = muc_jid
533 iq["pubsub"]["publish"]["item"]["payload"] = displayed
534 iq["pubsub"]["publish_options"] = PUBLISH_OPTIONS
535 try:
536 await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
537 except Exception as e:
538 self._session.log.debug("Could not MDS mark", exc_info=e)
541log = logging.getLogger(__name__)