Coverage for slidge/group/room.py: 87%
674 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-07 05:11 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-07 05:11 +0000
1import json
2import logging
3import re
4import string
5import warnings
6from copy import copy
7from datetime import datetime, timedelta, timezone
8from typing import TYPE_CHECKING, AsyncIterator, Generic, Optional, Self, Union
9from uuid import uuid4
11from slixmpp import JID, Iq, Message, Presence
12from slixmpp.exceptions import IqError, IqTimeout, XMPPError
13from slixmpp.jid import _unescape_node
14from slixmpp.plugins.xep_0004 import Form
15from slixmpp.plugins.xep_0060.stanza import Item
16from slixmpp.plugins.xep_0082 import parse as str_to_datetime
17from slixmpp.xmlstream import ET
19from ..contact.contact import LegacyContact
20from ..contact.roster import ContactIsUser
21from ..core import config
22from ..core.mixins import StoredAttributeMixin
23from ..core.mixins.avatar import AvatarMixin
24from ..core.mixins.db import UpdateInfoMixin
25from ..core.mixins.disco import ChatterDiscoMixin
26from ..core.mixins.lock import NamedLockMixin
27from ..core.mixins.recipient import ReactionRecipientMixin, ThreadRecipientMixin
28from ..db.models import Room
29from ..util import ABCSubclassableOnceAtMost
30from ..util.types import (
31 HoleBound,
32 LegacyGroupIdType,
33 LegacyMessageType,
34 LegacyParticipantType,
35 LegacyUserIdType,
36 Mention,
37 MucAffiliation,
38 MucType,
39)
40from ..util.util import deprecated, timeit, with_session
41from .archive import MessageArchive
42from .participant import LegacyParticipant
44if TYPE_CHECKING:
45 from ..core.gateway import BaseGateway
46 from ..core.session import BaseSession
48ADMIN_NS = "http://jabber.org/protocol/muc#admin"
50SubjectSetterType = Union[str, None, "LegacyContact", "LegacyParticipant"]
53class LegacyMUC(
54 Generic[
55 LegacyGroupIdType, LegacyMessageType, LegacyParticipantType, LegacyUserIdType
56 ],
57 UpdateInfoMixin,
58 StoredAttributeMixin,
59 AvatarMixin,
60 NamedLockMixin,
61 ChatterDiscoMixin,
62 ReactionRecipientMixin,
63 ThreadRecipientMixin,
64 metaclass=ABCSubclassableOnceAtMost,
65):
66 """
67 A room, a.k.a. a Multi-User Chat.
69 MUC instances are obtained by calling :py:meth:`slidge.group.bookmarks.LegacyBookmarks`
70 on the user's :py:class:`slidge.core.session.BaseSession`.
71 """
73 max_history_fetch = 100
75 type = MucType.CHANNEL
76 is_group = True
78 DISCO_TYPE = "text"
79 DISCO_CATEGORY = "conference"
80 DISCO_NAME = "unnamed-room"
82 STABLE_ARCHIVE = False
83 """
84 Because legacy events like reactions, editions, etc. don't all map to a stanza
85 with a proper legacy ID, slidge usually cannot guarantee the stability of the archive
86 across restarts.
88 Set this to True if you know what you're doing, but realistically, this can't
89 be set to True until archive is permanently stored on disk by slidge.
91 This is just a flag on archive responses that most clients ignore anyway.
92 """
94 KEEP_BACKFILLED_PARTICIPANTS = False
95 """
96 Set this to ``True`` if the participant list is not full after calling
97 ``fill_participants()``. This is a workaround for networks with huge
98 participant lists which do not map really well the MUCs where all presences
99 are sent on join.
100 It allows to ensure that the participants that last spoke (within the
101 ``fill_history()`` method are effectively participants, thus making possible
102 for XMPP clients to fetch their avatars.
103 """
105 _ALL_INFO_FILLED_ON_STARTUP = False
106 """
107 Set this to true if the fill_participants() / fill_participants() design does not
108 fit the legacy API, ie, no lazy loading of the participant list and history.
109 """
111 HAS_DESCRIPTION = True
112 """
113 Set this to false if the legacy network does not allow setting a description
114 for the group. In this case the description field will not be present in the
115 room configuration form.
116 """
118 HAS_SUBJECT = True
119 """
120 Set this to false if the legacy network does not allow setting a subject
121 (sometimes also called topic) for the group. In this case, as a subject is
122 recommended by :xep:`0045` ("SHALL"), the description (or the group name as
123 ultimate fallback) will be used as the room subject.
124 By setting this to false, an error will be returned when the :term:`User`
125 tries to set the room subject.
126 """
128 _avatar_bare_jid = True
129 archive: MessageArchive
131 def __init__(self, session: "BaseSession", legacy_id: LegacyGroupIdType, jid: JID):
132 self.session = session
133 self.xmpp: "BaseGateway" = session.xmpp
135 self.legacy_id = legacy_id
136 self.jid = jid
138 self._user_resources = set[str]()
140 self.Participant = LegacyParticipant.get_self_or_unique_subclass()
142 self._subject = ""
143 self._subject_setter: Optional[str] = None
145 self.pk: Optional[int] = None
146 self._user_nick: Optional[str] = None
148 self._participants_filled = False
149 self._history_filled = False
150 self._description = ""
151 self._subject_date: Optional[datetime] = None
153 self.__participants_store = self.xmpp.store.participants
154 self.__store = self.xmpp.store.rooms
156 self._n_participants: Optional[int] = None
158 self.log = logging.getLogger(self.jid.bare)
159 self._set_logger_name()
160 super().__init__()
162 @property
163 def n_participants(self):
164 return self._n_participants
166 @n_participants.setter
167 def n_participants(self, n_participants: Optional[int]):
168 if self._n_participants == n_participants:
169 return
170 self._n_participants = n_participants
171 if self._updating_info:
172 return
173 assert self.pk is not None
174 self.__store.update_n_participants(self.pk, n_participants)
176 @property
177 def user_jid(self):
178 return self.session.user_jid
180 def _set_logger_name(self):
181 self.log = logging.getLogger(f"{self.user_jid}:muc:{self}")
183 def __repr__(self):
184 return f"<MUC #{self.pk} '{self.name}' ({self.legacy_id} - {self.jid.local})'>"
186 @property
187 def subject_date(self) -> Optional[datetime]:
188 return self._subject_date
190 @subject_date.setter
191 def subject_date(self, when: Optional[datetime]) -> None:
192 self._subject_date = when
193 if self._updating_info:
194 return
195 assert self.pk is not None
196 self.__store.update_subject_date(self.pk, when)
198 def __send_configuration_change(self, codes):
199 part = self.get_system_participant()
200 part.send_configuration_change(codes)
202 @property
203 def user_nick(self):
204 return self._user_nick or self.session.bookmarks.user_nick or self.user_jid.node
206 @user_nick.setter
207 def user_nick(self, nick: str):
208 self._user_nick = nick
209 if not self._updating_info:
210 self.__store.update_user_nick(self.pk, nick)
212 def add_user_resource(self, resource: str) -> None:
213 self._user_resources.add(resource)
214 assert self.pk is not None
215 self.__store.set_resource(self.pk, self._user_resources)
217 def get_user_resources(self) -> set[str]:
218 return self._user_resources
220 def remove_user_resource(self, resource: str) -> None:
221 self._user_resources.remove(resource)
222 assert self.pk is not None
223 self.__store.set_resource(self.pk, self._user_resources)
225 async def __fill_participants(self):
226 if self._participants_filled:
227 return
228 assert self.pk is not None
229 async with self.lock("fill participants"):
230 self._participants_filled = True
231 async for p in self.fill_participants():
232 self.__participants_store.update(p)
233 self.__store.set_participants_filled(self.pk)
235 async def get_participants(self) -> AsyncIterator[LegacyParticipant]:
236 assert self.pk is not None
237 if self._participants_filled:
238 for db_participant in self.xmpp.store.participants.get_all(
239 self.pk, user_included=True
240 ):
241 participant = self.Participant.from_store(
242 self.session, db_participant, muc=self
243 )
244 yield participant
245 return
247 async with self.lock("fill participants"):
248 self._participants_filled = True
249 # We only fill the participants list if/when the MUC is first
250 # joined by an XMPP client. But we may have instantiated
251 resources = set[str]()
252 for db_participant in self.xmpp.store.participants.get_all(
253 self.pk, user_included=True
254 ):
255 participant = self.Participant.from_store(
256 self.session, db_participant, muc=self
257 )
258 resources.add(participant.jid.resource)
259 yield participant
260 async for p in self.fill_participants():
261 if p.jid.resource not in resources:
262 yield p
263 self.__store.set_participants_filled(self.pk)
264 return
266 async def __fill_history(self):
267 async with self.lock("fill history"):
268 if self._history_filled:
269 log.debug("History has already been fetched %s", self)
270 return
271 log.debug("Fetching history for %s", self)
272 try:
273 before, after = self.archive.get_hole_bounds()
274 if before is not None:
275 before = before._replace(
276 id=self.xmpp.LEGACY_MSG_ID_TYPE(before.id) # type:ignore
277 )
278 if after is not None:
279 after = after._replace(
280 id=self.xmpp.LEGACY_MSG_ID_TYPE(after.id) # type:ignore
281 )
282 await self.backfill(before, after)
283 except NotImplementedError:
284 return
285 except Exception as e:
286 log.exception("Could not backfill: %s", e)
287 assert self.pk is not None
288 self.__store.set_history_filled(self.pk, True)
289 self._history_filled = True
291 @property
292 def name(self):
293 return self.DISCO_NAME
295 @name.setter
296 def name(self, n: str):
297 if self.DISCO_NAME == n:
298 return
299 self.DISCO_NAME = n
300 self._set_logger_name()
301 self.__send_configuration_change((104,))
302 if self._updating_info:
303 return
304 assert self.pk is not None
305 self.__store.update_name(self.pk, n)
307 @property
308 def description(self):
309 return self._description
311 @description.setter
312 def description(self, d: str):
313 if self._description == d:
314 return
315 self._description = d
316 self.__send_configuration_change((104,))
317 if self._updating_info:
318 return
319 assert self.pk is not None
320 self.__store.update_description(self.pk, d)
322 def on_presence_unavailable(self, p: Presence):
323 pto = p.get_to()
324 if pto.bare != self.jid.bare:
325 return
327 pfrom = p.get_from()
328 if pfrom.bare != self.user_jid.bare:
329 return
330 if (resource := pfrom.resource) in self._user_resources:
331 if pto.resource != self.user_nick:
332 self.log.debug(
333 "Received 'leave group' request but with wrong nickname. %s", p
334 )
335 self.remove_user_resource(resource)
336 else:
337 self.log.debug(
338 "Received 'leave group' request but resource was not listed. %s", p
339 )
341 async def update_info(self):
342 """
343 Fetch information about this group from the legacy network
345 This is awaited on MUC instantiation, and should be overridden to
346 update the attributes of the group chat, like title, subject, number
347 of participants etc.
349 To take advantage of the slidge avatar cache, you can check the .avatar
350 property to retrieve the "legacy file ID" of the cached avatar. If there
351 is no change, you should not call
352 :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar()` or
353 attempt to modify
354 the :attr:.avatar property.
355 """
356 raise NotImplementedError
358 async def backfill(
359 self,
360 after: Optional[HoleBound] = None,
361 before: Optional[HoleBound] = None,
362 ):
363 """
364 Override this if the legacy network provide server-side group archives.
366 In it, send history messages using ``self.get_participant(xxx).send_xxxx``,
367 with the ``archive_only=True`` kwarg. This is only called once per slidge
368 run for a given group.
370 :param after: Fetch messages after this one. If ``None``, it's up to you
371 to decide how far you want to go in the archive. If it's not ``None``,
372 it means slidge has some messages in this archive and you should really try
373 to complete it to avoid "holes" in the history of this group.
374 :param before: Fetch messages before this one. If ``None``, fetch all messages
375 up to the most recent one
376 """
377 raise NotImplementedError
379 async def fill_participants(self) -> AsyncIterator[LegacyParticipant]:
380 """
381 This method should yield the list of all members of this group.
383 Typically, use ``participant = self.get_participant()``, self.get_participant_by_contact(),
384 of self.get_user_participant(), and update their affiliation, hats, etc.
385 before yielding them.
386 """
387 return
388 yield
390 @property
391 def subject(self):
392 return self._subject
394 @subject.setter
395 def subject(self, s: str):
396 if s == self._subject:
397 return
398 self.__get_subject_setter_participant().set_room_subject(
399 s, None, self.subject_date, False
400 )
402 self._subject = s
403 if self._updating_info:
404 return
405 assert self.pk is not None
406 self.__store.update_subject(self.pk, s)
408 @property
409 def is_anonymous(self):
410 return self.type == MucType.CHANNEL
412 @property
413 def subject_setter(self) -> Optional[str]:
414 return self._subject_setter
416 @subject_setter.setter
417 def subject_setter(self, subject_setter: SubjectSetterType) -> None:
418 if isinstance(subject_setter, LegacyContact):
419 subject_setter = subject_setter.name
420 elif isinstance(subject_setter, LegacyParticipant):
421 subject_setter = subject_setter.nickname
423 if subject_setter == self._subject_setter:
424 return
425 assert isinstance(subject_setter, str)
426 self._subject_setter = subject_setter
427 if self._updating_info:
428 return
429 assert self.pk is not None
430 self.__store.update_subject_setter(self.pk, subject_setter)
432 def __get_subject_setter_participant(self) -> LegacyParticipant:
433 if self._subject_setter is None:
434 return self.get_system_participant()
435 return self.Participant(self, self._subject_setter)
437 def features(self):
438 features = [
439 "http://jabber.org/protocol/muc",
440 "http://jabber.org/protocol/muc#stable_id",
441 "http://jabber.org/protocol/muc#self-ping-optimization",
442 "urn:xmpp:mam:2",
443 "urn:xmpp:mam:2#extended",
444 "urn:xmpp:sid:0",
445 "muc_persistent",
446 "vcard-temp",
447 "urn:xmpp:ping",
448 "urn:xmpp:occupant-id:0",
449 "jabber:iq:register",
450 self.xmpp.plugin["xep_0425"].stanza.NS,
451 ]
452 if self.type == MucType.GROUP:
453 features.extend(["muc_membersonly", "muc_nonanonymous", "muc_hidden"])
454 elif self.type == MucType.CHANNEL:
455 features.extend(["muc_open", "muc_semianonymous", "muc_public"])
456 elif self.type == MucType.CHANNEL_NON_ANONYMOUS:
457 features.extend(["muc_open", "muc_nonanonymous", "muc_public"])
458 return features
460 async def extended_features(self):
461 is_group = self.type == MucType.GROUP
463 form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
465 form.add_field(
466 "FORM_TYPE", "hidden", value="http://jabber.org/protocol/muc#roominfo"
467 )
468 form.add_field("muc#roomconfig_persistentroom", "boolean", value=True)
469 form.add_field("muc#roomconfig_changesubject", "boolean", value=False)
470 form.add_field("muc#maxhistoryfetch", value=str(self.max_history_fetch))
471 form.add_field("muc#roominfo_subjectmod", "boolean", value=False)
473 if self._ALL_INFO_FILLED_ON_STARTUP or self._participants_filled:
474 assert self.pk is not None
475 n: Optional[int] = self.__participants_store.get_count(self.pk)
476 else:
477 n = self._n_participants
478 if n is not None:
479 form.add_field("muc#roominfo_occupants", value=str(n))
481 if d := self.description:
482 form.add_field("muc#roominfo_description", value=d)
484 if s := self.subject:
485 form.add_field("muc#roominfo_subject", value=s)
487 if self._set_avatar_task:
488 await self._set_avatar_task
489 avatar = self.get_avatar()
490 if avatar and (h := avatar.id):
491 form.add_field(
492 "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1", value=h
493 )
494 form.add_field("muc#roominfo_avatarhash", "text-multi", value=[h])
496 form.add_field("muc#roomconfig_membersonly", "boolean", value=is_group)
497 form.add_field(
498 "muc#roomconfig_whois",
499 "list-single",
500 value="moderators" if self.is_anonymous else "anyone",
501 )
502 form.add_field("muc#roomconfig_publicroom", "boolean", value=not is_group)
503 form.add_field("muc#roomconfig_allowpm", "boolean", value=False)
505 r = [form]
507 if reaction_form := await self.restricted_emoji_extended_feature():
508 r.append(reaction_form)
510 return r
512 def shutdown(self):
513 user_jid = copy(self.jid)
514 user_jid.resource = self.user_nick
515 for user_full_jid in self.user_full_jids():
516 presence = self.xmpp.make_presence(
517 pfrom=user_jid, pto=user_full_jid, ptype="unavailable"
518 )
519 presence["muc"]["affiliation"] = "none"
520 presence["muc"]["role"] = "none"
521 presence["muc"]["status_codes"] = {110, 332}
522 presence.send()
524 def user_full_jids(self):
525 for r in self._user_resources:
526 j = copy(self.user_jid)
527 j.resource = r
528 yield j
530 @property
531 def user_muc_jid(self):
532 user_muc_jid = copy(self.jid)
533 user_muc_jid.resource = self.user_nick
534 return user_muc_jid
536 def _legacy_to_xmpp(self, legacy_id: LegacyMessageType):
537 return self.xmpp.store.sent.get_group_xmpp_id(
538 self.session.user_pk, str(legacy_id)
539 ) or self.session.legacy_to_xmpp_msg_id(legacy_id)
541 async def echo(
542 self, msg: Message, legacy_msg_id: Optional[LegacyMessageType] = None
543 ):
544 origin_id = msg.get_origin_id()
546 msg.set_from(self.user_muc_jid)
547 msg.set_id(msg.get_id())
548 if origin_id:
549 # because of slixmpp internal magic, we need to do this to ensure the origin_id
550 # is present
551 set_origin_id(msg, origin_id)
552 if legacy_msg_id:
553 msg["stanza_id"]["id"] = self.session.legacy_to_xmpp_msg_id(legacy_msg_id)
554 else:
555 msg["stanza_id"]["id"] = str(uuid4())
556 msg["stanza_id"]["by"] = self.jid
557 msg["occupant-id"]["id"] = "slidge-user"
559 self.archive.add(msg, await self.get_user_participant())
561 for user_full_jid in self.user_full_jids():
562 self.log.debug("Echoing to %s", user_full_jid)
563 msg = copy(msg)
564 msg.set_to(user_full_jid)
566 msg.send()
568 def _get_cached_avatar_id(self):
569 if self.pk is None:
570 return None
571 return self.xmpp.store.rooms.get_avatar_legacy_id(self.pk)
573 def _post_avatar_update(self) -> None:
574 if self.pk is None:
575 return
576 assert self.pk is not None
577 self.xmpp.store.rooms.set_avatar(
578 self.pk,
579 self._avatar_pk,
580 None if self.avatar_id is None else str(self.avatar_id),
581 )
582 self.__send_configuration_change((104,))
583 self._send_room_presence()
585 def _send_room_presence(self, user_full_jid: Optional[JID] = None):
586 if user_full_jid is None:
587 tos = self.user_full_jids()
588 else:
589 tos = [user_full_jid]
590 for to in tos:
591 p = self.xmpp.make_presence(pfrom=self.jid, pto=to)
592 if (avatar := self.get_avatar()) and (h := avatar.id):
593 p["vcard_temp_update"]["photo"] = h
594 else:
595 p["vcard_temp_update"]["photo"] = ""
596 p.send()
598 @timeit
599 @with_session
600 async def join(self, join_presence: Presence):
601 user_full_jid = join_presence.get_from()
602 requested_nickname = join_presence.get_to().resource
603 client_resource = user_full_jid.resource
605 if client_resource in self._user_resources:
606 self.log.debug("Received join from a resource that is already joined.")
608 self.add_user_resource(client_resource)
610 if not requested_nickname or not client_resource:
611 raise XMPPError("jid-malformed", by=self.jid)
613 self.log.debug(
614 "Resource %s of %s wants to join room %s with nickname %s",
615 client_resource,
616 self.user_jid,
617 self.legacy_id,
618 requested_nickname,
619 )
621 user_nick = self.user_nick
622 user_participant = None
623 async for participant in self.get_participants():
624 if participant.is_user:
625 user_participant = participant
626 continue
627 participant.send_initial_presence(full_jid=user_full_jid)
629 if user_participant is None:
630 user_participant = await self.get_user_participant()
631 if not user_participant.is_user: # type:ignore
632 self.log.warning("is_user flag not set participant on user_participant")
633 user_participant.is_user = True # type:ignore
634 user_participant.send_initial_presence(
635 user_full_jid,
636 presence_id=join_presence["id"],
637 nick_change=user_nick != requested_nickname,
638 )
640 history_params = join_presence["muc_join"]["history"]
641 maxchars = int_or_none(history_params["maxchars"])
642 maxstanzas = int_or_none(history_params["maxstanzas"])
643 seconds = int_or_none(history_params["seconds"])
644 try:
645 since = self.xmpp.plugin["xep_0082"].parse(history_params["since"])
646 except ValueError:
647 since = None
648 if seconds:
649 since = datetime.now() - timedelta(seconds=seconds)
650 if equals_zero(maxchars) or equals_zero(maxstanzas):
651 log.debug("Joining client does not want any old-school MUC history-on-join")
652 else:
653 self.log.debug("Old school history fill")
654 await self.__fill_history()
655 await self.__old_school_history(
656 user_full_jid,
657 maxchars=maxchars,
658 maxstanzas=maxstanzas,
659 since=since,
660 )
661 self.__get_subject_setter_participant().set_room_subject(
662 self._subject if self.HAS_SUBJECT else (self.description or self.name),
663 user_full_jid,
664 self.subject_date,
665 )
666 if t := self._set_avatar_task:
667 await t
668 self._send_room_presence(user_full_jid)
670 async def get_user_participant(self, **kwargs) -> "LegacyParticipantType":
671 """
672 Get the participant representing the gateway user
674 :param kwargs: additional parameters for the :class:`.Participant`
675 construction (optional)
676 :return:
677 """
678 p = await self.get_participant(self.user_nick, is_user=True, **kwargs)
679 self.__store_participant(p)
680 return p
682 def __store_participant(self, p: "LegacyParticipantType") -> None:
683 # we don't want to update the participant list when we're filling history
684 if not self.KEEP_BACKFILLED_PARTICIPANTS and self.get_lock("fill history"):
685 return
686 assert self.pk is not None
687 p.pk = self.__participants_store.add(self.pk, p.nickname)
688 self.__participants_store.update(p)
690 async def get_participant(
691 self,
692 nickname: str,
693 raise_if_not_found=False,
694 fill_first=False,
695 store=True,
696 **kwargs,
697 ) -> "LegacyParticipantType":
698 """
699 Get a participant by their nickname.
701 In non-anonymous groups, you probably want to use
702 :meth:`.LegacyMUC.get_participant_by_contact` instead.
704 :param nickname: Nickname of the participant (used as resource part in the MUC)
705 :param raise_if_not_found: Raise XMPPError("item-not-found") if they are not
706 in the participant list (internal use by slidge, plugins should not
707 need that)
708 :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called first
709 (internal use by slidge, plugins should not need that)
710 :param store: persistently store the user in the list of MUC participants
711 :param kwargs: additional parameters for the :class:`.Participant`
712 construction (optional)
713 :return:
714 """
715 if fill_first and not self._participants_filled:
716 async for _ in self.get_participants():
717 pass
718 if self.pk is not None:
719 with self.xmpp.store.session():
720 stored = self.__participants_store.get_by_nickname(
721 self.pk, nickname
722 ) or self.__participants_store.get_by_resource(self.pk, nickname)
723 if stored is not None:
724 return self.Participant.from_store(self.session, stored)
726 if raise_if_not_found:
727 raise XMPPError("item-not-found")
728 p = self.Participant(self, nickname, **kwargs)
729 if store and not self._updating_info:
730 self.__store_participant(p)
731 if (
732 not self.get_lock("fill participants")
733 and not self.get_lock("fill history")
734 and self._participants_filled
735 and not p.is_user
736 and not p.is_system
737 ):
738 p.send_affiliation_change()
739 return p
741 def get_system_participant(self) -> "LegacyParticipantType":
742 """
743 Get a pseudo-participant, representing the room itself
745 Can be useful for events that cannot be mapped to a participant,
746 e.g. anonymous moderation events, or announces from the legacy
747 service
748 :return:
749 """
750 return self.Participant(self, is_system=True)
752 async def get_participant_by_contact(
753 self, c: "LegacyContact", **kwargs
754 ) -> "LegacyParticipantType":
755 """
756 Get a non-anonymous participant.
758 This is what should be used in non-anonymous groups ideally, to ensure
759 that the Contact jid is associated to this participant
761 :param c: The :class:`.LegacyContact` instance corresponding to this contact
762 :param kwargs: additional parameters for the :class:`.Participant`
763 construction (optional)
764 :return:
765 """
766 await self.session.contacts.ready
768 if self.pk is not None:
769 c._LegacyContact__ensure_pk() # type: ignore
770 assert c.contact_pk is not None
771 with self.__store.session():
772 stored = self.__participants_store.get_by_contact(self.pk, c.contact_pk)
773 if stored is not None:
774 return self.Participant.from_store(
775 self.session, stored, muc=self, contact=c
776 )
778 nickname = c.name or _unescape_node(c.jid_username)
780 if self.pk is None:
781 nick_available = True
782 else:
783 nick_available = self.__store.nickname_is_available(self.pk, nickname)
785 if not nick_available:
786 self.log.debug("Nickname conflict")
787 nickname = f"{nickname} ({c.jid_username})"
788 p = self.Participant(self, nickname, **kwargs)
789 p.contact = c
791 if self._updating_info:
792 return p
794 self.__store_participant(p)
795 # FIXME: this is not great but given the current design,
796 # during participants fill and history backfill we do not
797 # want to send presence, because we might :update affiliation
798 # and role afterwards.
799 # We need a refactor of the MUC class… later™
800 if (
801 self._participants_filled
802 and not self.get_lock("fill participants")
803 and not self.get_lock("fill history")
804 ):
805 p.send_last_presence(force=True, no_cache_online=True)
806 return p
808 async def get_participant_by_legacy_id(
809 self, legacy_id: LegacyUserIdType, **kwargs
810 ) -> "LegacyParticipantType":
811 try:
812 c = await self.session.contacts.by_legacy_id(legacy_id)
813 except ContactIsUser:
814 return await self.get_user_participant(**kwargs)
815 return await self.get_participant_by_contact(c, **kwargs)
817 def remove_participant(
818 self,
819 p: "LegacyParticipantType",
820 kick=False,
821 ban=False,
822 reason: str | None = None,
823 ):
824 """
825 Call this when a participant leaves the room
827 :param p: The participant
828 :param kick: Whether the participant left because they were kicked
829 :param ban: Whether the participant left because they were banned
830 :param reason: Optionally, a reason why the participant was removed.
831 """
832 if kick and ban:
833 raise TypeError("Either kick or ban")
834 self.__participants_store.delete(p.pk)
835 if kick:
836 codes = {307}
837 elif ban:
838 codes = {301}
839 else:
840 codes = None
841 presence = p._make_presence(ptype="unavailable", status_codes=codes)
842 p._affiliation = "outcast" if ban else "none"
843 p._role = "none"
844 if reason:
845 presence["muc"].set_item_attr("reason", reason)
846 p._send(presence)
848 def rename_participant(self, old_nickname: str, new_nickname: str):
849 assert self.pk is not None
850 with self.xmpp.store.session():
851 stored = self.__participants_store.get_by_nickname(self.pk, old_nickname)
852 if stored is None:
853 self.log.debug("Tried to rename a participant that we didn't know")
854 return
855 p = self.Participant.from_store(self.session, stored)
856 if p.nickname == old_nickname:
857 p.nickname = new_nickname
859 async def __old_school_history(
860 self,
861 full_jid: JID,
862 maxchars: Optional[int] = None,
863 maxstanzas: Optional[int] = None,
864 seconds: Optional[int] = None,
865 since: Optional[datetime] = None,
866 ):
867 """
868 Old-style history join (internal slidge use)
870 :param full_jid:
871 :param maxchars:
872 :param maxstanzas:
873 :param seconds:
874 :param since:
875 :return:
876 """
877 if since is None:
878 if seconds is None:
879 start_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
880 else:
881 start_date = datetime.now(tz=timezone.utc) - timedelta(seconds=seconds)
882 else:
883 start_date = since or datetime.now(tz=timezone.utc) - timedelta(days=1)
885 for h_msg in self.archive.get_all(
886 start_date=start_date, end_date=None, last_page_n=maxstanzas
887 ):
888 msg = h_msg.stanza_component_ns
889 msg["delay"]["stamp"] = h_msg.when
890 msg.set_to(full_jid)
891 self.xmpp.send(msg, False)
893 async def send_mam(self, iq: Iq):
894 await self.__fill_history()
896 form_values = iq["mam"]["form"].get_values()
898 start_date = str_to_datetime_or_none(form_values.get("start"))
899 end_date = str_to_datetime_or_none(form_values.get("end"))
901 after_id = form_values.get("after-id")
902 before_id = form_values.get("before-id")
904 sender = form_values.get("with")
906 ids = form_values.get("ids") or ()
908 if max_str := iq["mam"]["rsm"]["max"]:
909 try:
910 max_results = int(max_str)
911 except ValueError:
912 max_results = None
913 else:
914 max_results = None
916 after_id_rsm = iq["mam"]["rsm"]["after"]
917 after_id = after_id_rsm or after_id
919 before_rsm = iq["mam"]["rsm"]["before"]
920 if before_rsm is True and max_results is not None:
921 last_page_n = max_results
922 else:
923 last_page_n = None
925 first = None
926 last = None
927 count = 0
929 it = self.archive.get_all(
930 start_date,
931 end_date,
932 before_id,
933 after_id,
934 ids,
935 last_page_n,
936 sender,
937 bool(iq["mam"]["flip_page"]),
938 )
940 for history_msg in it:
941 last = xmpp_id = history_msg.id
942 if first is None:
943 first = xmpp_id
945 wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from())
946 wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"]
947 wrapper_msg["mam_result"]["id"] = xmpp_id
948 wrapper_msg["mam_result"].append(history_msg.forwarded())
950 wrapper_msg.send()
951 count += 1
953 if max_results and count == max_results:
954 break
956 if max_results:
957 try:
958 next(it)
959 except StopIteration:
960 complete = True
961 else:
962 complete = False
963 else:
964 complete = True
966 reply = iq.reply()
967 if not self.STABLE_ARCHIVE:
968 reply["mam_fin"]["stable"] = "false"
969 if complete:
970 reply["mam_fin"]["complete"] = "true"
971 reply["mam_fin"]["rsm"]["first"] = first
972 reply["mam_fin"]["rsm"]["last"] = last
973 reply["mam_fin"]["rsm"]["count"] = str(count)
974 reply.send()
976 async def send_mam_metadata(self, iq: Iq):
977 await self.__fill_history()
978 await self.archive.send_metadata(iq)
980 async def kick_resource(self, r: str):
981 """
982 Kick a XMPP client of the user. (slidge internal use)
984 :param r: The resource to kick
985 """
986 pto = self.user_jid
987 pto.resource = r
988 p = self.xmpp.make_presence(
989 pfrom=(await self.get_user_participant()).jid, pto=pto
990 )
991 p["type"] = "unavailable"
992 p["muc"]["affiliation"] = "none"
993 p["muc"]["role"] = "none"
994 p["muc"]["status_codes"] = {110, 333}
995 p.send()
997 async def add_to_bookmarks(self, auto_join=True, invite=False, preserve=True):
998 """
999 Add the MUC to the user's XMPP bookmarks (:xep:`0402')
1001 This requires that slidge has the IQ privileged set correctly
1002 on the XMPP server
1004 :param auto_join: whether XMPP clients should automatically join
1005 this MUC on startup. In theory, XMPP clients will receive
1006 a "push" notification when this is called, and they will
1007 join if they are online.
1008 :param invite: send an invitation to join this MUC emanating from
1009 the gateway. While this should not be strictly necessary,
1010 it can help for clients that do not support :xep:`0402`, or
1011 that have 'do not honor bookmarks auto-join' turned on in their
1012 settings.
1013 :param preserve: preserve auto-join and bookmarks extensions
1014 set by the user outside slidge
1015 """
1016 item = Item()
1017 item["id"] = self.jid
1019 iq = Iq(stype="get", sfrom=self.user_jid, sto=self.user_jid)
1020 iq["pubsub"]["items"]["node"] = self.xmpp["xep_0402"].stanza.NS
1021 iq["pubsub"]["items"].append(item)
1023 is_update = False
1024 if preserve:
1025 try:
1026 ans = await self.xmpp["xep_0356"].send_privileged_iq(iq)
1027 is_update = len(ans["pubsub"]["items"]) == 1
1028 # this below creates the item if it wasn't here already
1029 # (slixmpp annoying magic)
1030 item = ans["pubsub"]["items"]["item"]
1031 item["id"] = self.jid
1032 except (IqError, IqTimeout):
1033 item["conference"]["autojoin"] = auto_join
1034 except PermissionError:
1035 warnings.warn(
1036 "IQ privileges (XEP0356) are not set, we cannot fetch the user bookmarks"
1037 )
1038 else:
1039 # if the bookmark is already present, we preserve it as much as
1040 # possible, especially custom <extensions>
1041 self.log.debug("Existing: %s", item)
1042 # if it's an update, we do not touch the auto join flag
1043 if not is_update:
1044 item["conference"]["autojoin"] = auto_join
1045 else:
1046 item["conference"]["autojoin"] = auto_join
1048 item["conference"]["nick"] = self.user_nick
1049 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid)
1050 iq["pubsub"]["publish"]["node"] = self.xmpp["xep_0402"].stanza.NS
1051 iq["pubsub"]["publish"].append(item)
1053 iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS
1055 try:
1056 await self.xmpp["xep_0356"].send_privileged_iq(iq)
1057 except PermissionError:
1058 warnings.warn(
1059 "IQ privileges (XEP0356) are not set, we cannot add bookmarks for the user"
1060 )
1061 # fallback by forcing invitation
1062 invite = True
1063 except IqError as e:
1064 warnings.warn(
1065 f"Something went wrong while trying to set the bookmarks: {e}"
1066 )
1067 # fallback by forcing invitation
1068 invite = True
1070 if invite or (config.ALWAYS_INVITE_WHEN_ADDING_BOOKMARKS and not is_update):
1071 self.session.send_gateway_invite(
1072 self, reason="This group could not be added automatically for you"
1073 )
1075 async def on_avatar(
1076 self, data: Optional[bytes], mime: Optional[str]
1077 ) -> Optional[Union[int, str]]:
1078 """
1079 Called when the user tries to set the avatar of the room from an XMPP
1080 client.
1082 If the set avatar operation is completed, should return a legacy image
1083 unique identifier. In this case the MUC avatar will be immediately
1084 updated on the XMPP side.
1086 If data is not None and this method returns None, then we assume that
1087 self.set_avatar() will be called elsewhere, eg triggered by a legacy
1088 room update event.
1090 :param data: image data or None if the user meant to remove the avatar
1091 :param mime: the mime type of the image. Since this is provided by
1092 the XMPP client, there is no guarantee that this is valid or
1093 correct.
1094 :return: A unique avatar identifier, which will trigger
1095 :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if
1096 :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else.
1097 """
1098 raise NotImplementedError
1100 admin_set_avatar = deprecated("LegacyMUC.on_avatar", on_avatar)
1102 async def on_set_affiliation(
1103 self,
1104 contact: "LegacyContact",
1105 affiliation: MucAffiliation,
1106 reason: Optional[str],
1107 nickname: Optional[str],
1108 ):
1109 """
1110 Triggered when the user requests changing the affiliation of a contact
1111 for this group.
1113 Examples: promotion them to moderator, ban (affiliation=outcast).
1115 :param contact: The contact whose affiliation change is requested
1116 :param affiliation: The new affiliation
1117 :param reason: A reason for this affiliation change
1118 :param nickname:
1119 """
1120 raise NotImplementedError
1122 async def on_kick(self, contact: "LegacyContact", reason: Optional[str]):
1123 """
1124 Triggered when the user requests changing the role of a contact
1125 to "none" for this group. Action commonly known as "kick".
1127 :param contact: Contact to be kicked
1128 :param reason: A reason for this kick
1129 """
1130 raise NotImplementedError
1132 async def on_set_config(
1133 self,
1134 name: Optional[str],
1135 description: Optional[str],
1136 ):
1137 """
1138 Triggered when the user requests changing the room configuration.
1139 Only title and description can be changed at the moment.
1141 The legacy module is responsible for updating :attr:`.title` and/or
1142 :attr:`.description` of this instance.
1144 If :attr:`.HAS_DESCRIPTION` is set to False, description will always
1145 be ``None``.
1147 :param name: The new name of the room.
1148 :param description: The new description of the room.
1149 """
1150 raise NotImplementedError
1152 async def on_destroy_request(self, reason: Optional[str]):
1153 """
1154 Triggered when the user requests room destruction.
1156 :param reason: Optionally, a reason for the destruction
1157 """
1158 raise NotImplementedError
1160 async def parse_mentions(self, text: str) -> list[Mention]:
1161 with self.__store.session():
1162 await self.__fill_participants()
1163 assert self.pk is not None
1164 participants = {
1165 p.nickname: p for p in self.__participants_store.get_all(self.pk)
1166 }
1168 if len(participants) == 0:
1169 return []
1171 result = []
1172 for match in re.finditer(
1173 "|".join(
1174 sorted(
1175 [re.escape(nick) for nick in participants.keys()],
1176 key=lambda nick: len(nick),
1177 reverse=True,
1178 )
1179 ),
1180 text,
1181 ):
1182 span = match.span()
1183 nick = match.group()
1184 if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION:
1185 continue
1186 if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION:
1187 participant = self.Participant.from_store(
1188 self.session, participants[nick]
1189 )
1190 if contact := participant.contact:
1191 result.append(
1192 Mention(contact=contact, start=span[0], end=span[1])
1193 )
1194 return result
1196 async def on_set_subject(self, subject: str) -> None:
1197 """
1198 Triggered when the user requests changing the room subject.
1200 The legacy module is responsible for updating :attr:`.subject` of this
1201 instance.
1203 :param subject: The new subject for this room.
1204 """
1205 raise NotImplementedError
1207 @classmethod
1208 def from_store(cls, session, stored: Room, *args, **kwargs) -> Self:
1209 muc = cls(
1210 session,
1211 cls.xmpp.LEGACY_ROOM_ID_TYPE(stored.legacy_id),
1212 stored.jid,
1213 *args, # type: ignore
1214 **kwargs, # type: ignore
1215 )
1216 muc.pk = stored.id
1217 muc.type = stored.muc_type # type: ignore
1218 muc._user_nick = stored.user_nick
1219 if stored.name:
1220 muc.DISCO_NAME = stored.name
1221 if stored.description:
1222 muc._description = stored.description
1223 if (data := stored.extra_attributes) is not None:
1224 muc.deserialize_extra_attributes(data)
1225 muc._subject = stored.subject or ""
1226 if stored.subject_date is not None:
1227 muc._subject_date = stored.subject_date.replace(tzinfo=timezone.utc)
1228 muc._participants_filled = stored.participants_filled
1229 muc._n_participants = stored.n_participants
1230 muc._history_filled = stored.history_filled
1231 if stored.user_resources is not None:
1232 muc._user_resources = set(json.loads(stored.user_resources))
1233 muc._subject_setter = stored.subject_setter
1234 muc.archive = MessageArchive(muc.pk, session.xmpp.store.mam)
1235 muc._set_logger_name()
1236 muc._AvatarMixin__avatar_unique_id = ( # type:ignore
1237 None
1238 if stored.avatar_legacy_id is None
1239 else session.xmpp.AVATAR_ID_TYPE(stored.avatar_legacy_id)
1240 )
1241 muc._avatar_pk = stored.avatar_id
1242 return muc
1245def set_origin_id(msg: Message, origin_id: str):
1246 sub = ET.Element("{urn:xmpp:sid:0}origin-id")
1247 sub.attrib["id"] = origin_id
1248 msg.xml.append(sub)
1251def int_or_none(x):
1252 try:
1253 return int(x)
1254 except ValueError:
1255 return None
1258def equals_zero(x):
1259 if x is None:
1260 return False
1261 else:
1262 return x == 0
1265def str_to_datetime_or_none(date: Optional[str]):
1266 if date is None:
1267 return
1268 try:
1269 return str_to_datetime(date)
1270 except ValueError:
1271 return None
1274def bookmarks_form():
1275 form = Form()
1276 form["type"] = "submit"
1277 form.add_field(
1278 "FORM_TYPE",
1279 value="http://jabber.org/protocol/pubsub#publish-options",
1280 ftype="hidden",
1281 )
1282 form.add_field("pubsub#persist_items", value="1")
1283 form.add_field("pubsub#max_items", value="max")
1284 form.add_field("pubsub#send_last_published_item", value="never")
1285 form.add_field("pubsub#access_model", value="whitelist")
1286 return form
1289_BOOKMARKS_OPTIONS = bookmarks_form()
1290_WHITESPACE_OR_PUNCTUATION = string.whitespace + string.punctuation
1292log = logging.getLogger(__name__)