Coverage for slidge/group/room.py: 88%
706 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-04 08:17 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-04 08:17 +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, Type, Union
9from uuid import uuid4
11import sqlalchemy as sa
12from slixmpp import JID, Iq, Message, Presence
13from slixmpp.exceptions import IqError, IqTimeout, XMPPError
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.plugins.xep_0469.stanza import NS as PINNING_NS
18from slixmpp.plugins.xep_0492.stanza import NS as NOTIFY_NS
19from slixmpp.plugins.xep_0492.stanza import WhenLiteral
20from slixmpp.xmlstream import ET
21from sqlalchemy.orm import Session as OrmSession
23from ..contact.contact import LegacyContact
24from ..contact.roster import ContactIsUser
25from ..core.mixins.avatar import AvatarMixin
26from ..core.mixins.disco import ChatterDiscoMixin
27from ..core.mixins.recipient import ReactionRecipientMixin, ThreadRecipientMixin
28from ..db.models import Participant, Room
29from ..util.jid_escaping import unescape_node
30from ..util.lock import NamedLockMixin
31from ..util.types import (
32 HoleBound,
33 LegacyGroupIdType,
34 LegacyMessageType,
35 LegacyParticipantType,
36 LegacyUserIdType,
37 Mention,
38 MucAffiliation,
39 MucType,
40)
41from ..util.util import SubclassableOnce, deprecated, timeit
42from .archive import MessageArchive
43from .participant import LegacyParticipant, escape_nickname
45if TYPE_CHECKING:
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 AvatarMixin,
58 NamedLockMixin,
59 ChatterDiscoMixin,
60 ReactionRecipientMixin,
61 ThreadRecipientMixin,
62 metaclass=SubclassableOnce,
63):
64 """
65 A room, a.k.a. a Multi-User Chat.
67 MUC instances are obtained by calling :py:meth:`slidge.group.bookmarks.LegacyBookmarks`
68 on the user's :py:class:`slidge.core.session.BaseSession`.
69 """
71 max_history_fetch = 100
73 is_group = True
75 DISCO_TYPE = "text"
76 DISCO_CATEGORY = "conference"
78 STABLE_ARCHIVE = False
79 """
80 Because legacy events like reactions, editions, etc. don't all map to a stanza
81 with a proper legacy ID, slidge usually cannot guarantee the stability of the archive
82 across restarts.
84 Set this to True if you know what you're doing, but realistically, this can't
85 be set to True until archive is permanently stored on disk by slidge.
87 This is just a flag on archive responses that most clients ignore anyway.
88 """
90 KEEP_BACKFILLED_PARTICIPANTS = False
91 """
92 Set this to ``True`` if the participant list is not full after calling
93 ``fill_participants()``. This is a workaround for networks with huge
94 participant lists which do not map really well the MUCs where all presences
95 are sent on join.
96 It allows to ensure that the participants that last spoke (within the
97 ``fill_history()`` method are effectively participants, thus making possible
98 for XMPP clients to fetch their avatars.
99 """
101 _ALL_INFO_FILLED_ON_STARTUP = False
102 """
103 Set this to true if the fill_participants() / fill_participants() design does not
104 fit the legacy API, ie, no lazy loading of the participant list and history.
105 """
107 HAS_DESCRIPTION = True
108 """
109 Set this to false if the legacy network does not allow setting a description
110 for the group. In this case the description field will not be present in the
111 room configuration form.
112 """
114 HAS_SUBJECT = True
115 """
116 Set this to false if the legacy network does not allow setting a subject
117 (sometimes also called topic) for the group. In this case, as a subject is
118 recommended by :xep:`0045` ("SHALL"), the description (or the group name as
119 ultimate fallback) will be used as the room subject.
120 By setting this to false, an error will be returned when the :term:`User`
121 tries to set the room subject.
122 """
124 archive: MessageArchive
125 session: "BaseSession"
127 stored: Room
129 _participant_cls: Type[LegacyParticipantType]
131 def __init__(self, session: "BaseSession", stored: Room) -> None:
132 self.session = session
133 self.xmpp = session.xmpp
134 self.stored = stored
135 self._set_logger()
136 super().__init__()
138 self.archive = MessageArchive(stored, self.xmpp.store.mam)
140 def participant_from_store(
141 self, stored: Participant, contact: LegacyContact | None = None
142 ) -> LegacyParticipantType:
143 if contact is None and stored.contact is not None:
144 contact = self.session.contacts.from_store(stored.contact)
145 return self._participant_cls(self, stored=stored, contact=contact)
147 @property
148 def jid(self) -> JID:
149 return self.stored.jid
151 @jid.setter
152 def jid(self, x: JID):
153 # FIXME: without this, mypy yields
154 # "Cannot override writeable attribute with read-only property"
155 # But it does not happen for LegacyContact. WTF?
156 raise RuntimeError
158 @property
159 def legacy_id(self):
160 return self.xmpp.LEGACY_ROOM_ID_TYPE(self.stored.legacy_id)
162 def orm(self) -> OrmSession:
163 return self.xmpp.store.session()
165 @property
166 def type(self) -> MucType:
167 return self.stored.muc_type
169 @type.setter
170 def type(self, type_: MucType) -> None:
171 if self.type == type_:
172 return
173 self.stored.muc_type = type_
174 self.commit()
176 @property
177 def n_participants(self):
178 return self.stored.n_participants
180 @n_participants.setter
181 def n_participants(self, n_participants: Optional[int]) -> None:
182 if self.stored.n_participants == n_participants:
183 return
184 self.stored.n_participants = n_participants
185 self.commit()
187 @property
188 def user_jid(self):
189 return self.session.user_jid
191 def _set_logger(self) -> None:
192 self.log = logging.getLogger(f"{self.user_jid}:muc:{self}")
194 def __repr__(self) -> str:
195 return f"<MUC #{self.stored.id} '{self.name}' ({self.stored.legacy_id} - {self.jid.user})'>"
197 @property
198 def subject_date(self) -> Optional[datetime]:
199 if self.stored.subject_date is None:
200 return None
201 return self.stored.subject_date.replace(tzinfo=timezone.utc)
203 @subject_date.setter
204 def subject_date(self, when: Optional[datetime]) -> None:
205 if self.subject_date == when:
206 return
207 self.stored.subject_date = when
208 self.commit()
210 def __send_configuration_change(self, codes) -> None:
211 part = self.get_system_participant()
212 part.send_configuration_change(codes)
214 @property
215 def user_nick(self):
216 return (
217 self.stored.user_nick
218 or self.session.bookmarks.user_nick
219 or self.user_jid.node
220 )
222 @user_nick.setter
223 def user_nick(self, nick: str) -> None:
224 if nick == self.user_nick:
225 return
226 self.stored.user_nick = nick
227 self.commit()
229 def add_user_resource(self, resource: str) -> None:
230 stored_set = self.get_user_resources()
231 if resource in stored_set:
232 return
233 stored_set.add(resource)
234 self.stored.user_resources = (
235 json.dumps(list(stored_set)) if stored_set else None
236 )
237 self.commit()
239 def get_user_resources(self) -> set[str]:
240 stored_str = self.stored.user_resources
241 if stored_str is None:
242 return set()
243 return set(json.loads(stored_str))
245 def remove_user_resource(self, resource: str) -> None:
246 stored_set = self.get_user_resources()
247 if resource not in stored_set:
248 return
249 stored_set.remove(resource)
250 self.stored.user_resources = (
251 json.dumps(list(stored_set)) if stored_set else None
252 )
253 self.commit()
255 async def __fill_participants(self) -> None:
256 if self.participants_filled:
257 return
258 async with self.lock("fill participants"):
259 parts: list[Participant] = []
260 resources: set[str] = set()
261 async for participant in self.fill_participants():
262 if participant.stored.id is not None:
263 continue
264 # During fill_participants(), self.get_participant*() methods may
265 # return a participant with a conflicting nick/resource. There is
266 # a better way to fix this than the logic below, but this better way
267 # has not been found yet.
268 if participant.jid.resource in resources:
269 if participant.contact is None:
270 self.log.warning(
271 "Ditching participant %s", participant.nickname
272 )
273 del participant
274 continue
275 else:
276 nickname = (
277 f"{participant.nickname} ({participant.contact.jid.node})"
278 )
279 participant = self._participant_cls(
280 self,
281 Participant(nickname=nickname, room=self.stored),
282 contact=participant.contact,
283 )
284 resources.add(participant.jid.resource)
285 parts.append(participant.stored)
286 with self.xmpp.store.session(expire_on_commit=False) as orm:
287 # FIXME: something must be wrong with all these refreshes and merge,
288 # but I did not manage to get rid of them without getting various
289 # sqlalchemy exceptions raised everywhere
290 orm.add(self.stored)
291 orm.refresh(self.stored)
292 known = {p.resource for p in self.stored.participants}
293 self.stored.participants_filled = True
294 for part in parts:
295 if part.resource in known:
296 continue
297 part = orm.merge(part)
298 orm.add(part)
299 self.stored.participants.append(part)
300 orm.commit()
301 orm.refresh(self.stored)
303 async def get_participants(
304 self, affiliation: Optional[MucAffiliation] = None
305 ) -> AsyncIterator[LegacyParticipantType]:
306 await self.__fill_participants()
307 with self.xmpp.store.session(expire_on_commit=False) as orm:
308 orm.add(self.stored)
309 for db_participant in self.stored.participants:
310 if (
311 affiliation is not None
312 and db_participant.affiliation != affiliation
313 ):
314 continue
315 yield self.participant_from_store(db_participant)
317 async def __fill_history(self) -> None:
318 if self.stored.history_filled:
319 self.log.debug("History has already been fetched.")
320 return
321 async with self.lock("fill history"):
322 log.debug("Fetching history for %s", self)
323 if not self.KEEP_BACKFILLED_PARTICIPANTS:
324 with self.xmpp.store.session() as orm:
325 orm.add(self.stored)
326 participants = list(self.stored.participants)
327 try:
328 before, after = self.archive.get_hole_bounds()
329 if before is not None:
330 before = before._replace(
331 id=self.xmpp.LEGACY_MSG_ID_TYPE(before.id) # type:ignore
332 )
333 if after is not None:
334 after = after._replace(
335 id=self.xmpp.LEGACY_MSG_ID_TYPE(after.id) # type:ignore
336 )
337 await self.backfill(before, after)
338 except NotImplementedError:
339 return
340 except Exception as e:
341 self.log.exception("Could not backfill", exc_info=e)
342 if not self.KEEP_BACKFILLED_PARTICIPANTS:
343 self.stored.participants = participants
344 self.stored.history_filled = True
345 self.commit(merge=True)
347 @property
348 def DISCO_NAME(self) -> str: # type:ignore
349 return self.name or "unnamed-room"
351 @property
352 def name(self) -> str:
353 return self.stored.name or "unnamed-room"
355 @name.setter
356 def name(self, n: str) -> None:
357 if self.name == n:
358 return
359 self.stored.name = n
360 self.commit()
361 self._set_logger()
362 self.__send_configuration_change((104,))
364 @property
365 def description(self):
366 return self.stored.description or ""
368 @description.setter
369 def description(self, d: str) -> None:
370 if self.description == d:
371 return
372 self.stored.description = d
373 self.commit()
374 self.__send_configuration_change((104,))
376 def on_presence_unavailable(self, p: Presence) -> None:
377 pto = p.get_to()
378 if pto.bare != self.jid.bare:
379 return
381 pfrom = p.get_from()
382 if pfrom.bare != self.user_jid.bare:
383 return
384 if (resource := pfrom.resource) in self.get_user_resources():
385 if pto.resource != self.user_nick:
386 self.log.debug(
387 "Received 'leave group' request but with wrong nickname. %s", p
388 )
389 self.remove_user_resource(resource)
390 else:
391 self.log.debug(
392 "Received 'leave group' request but resource was not listed. %s", p
393 )
395 async def update_info(self):
396 """
397 Fetch information about this group from the legacy network
399 This is awaited on MUC instantiation, and should be overridden to
400 update the attributes of the group chat, like title, subject, number
401 of participants etc.
403 To take advantage of the slidge avatar cache, you can check the .avatar
404 property to retrieve the "legacy file ID" of the cached avatar. If there
405 is no change, you should not call
406 :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar()` or
407 attempt to modify
408 the :attr:.avatar property.
409 """
410 raise NotImplementedError
412 async def backfill(
413 self,
414 after: Optional[HoleBound] = None,
415 before: Optional[HoleBound] = None,
416 ):
417 """
418 Override this if the legacy network provide server-side group archives.
420 In it, send history messages using ``self.get_participant(xxx).send_xxxx``,
421 with the ``archive_only=True`` kwarg. This is only called once per slidge
422 run for a given group.
424 :param after: Fetch messages after this one. If ``None``, it's up to you
425 to decide how far you want to go in the archive. If it's not ``None``,
426 it means slidge has some messages in this archive and you should really try
427 to complete it to avoid "holes" in the history of this group.
428 :param before: Fetch messages before this one. If ``None``, fetch all messages
429 up to the most recent one
430 """
431 raise NotImplementedError
433 async def fill_participants(self) -> AsyncIterator[LegacyParticipantType]:
434 """
435 This method should yield the list of all members of this group.
437 Typically, use ``participant = self.get_participant()``, self.get_participant_by_contact(),
438 of self.get_user_participant(), and update their affiliation, hats, etc.
439 before yielding them.
440 """
441 return
442 yield
444 @property
445 def subject(self):
446 return self.stored.subject
448 @subject.setter
449 def subject(self, s: str) -> None:
450 if s == self.subject:
451 return
453 self.stored.subject = s
454 self.commit()
455 self.__get_subject_setter_participant().set_room_subject(
456 s, None, self.subject_date, False
457 )
459 @property
460 def is_anonymous(self):
461 return self.type == MucType.CHANNEL
463 @property
464 def subject_setter(self) -> Optional[str]:
465 return self.stored.subject_setter
467 @subject_setter.setter
468 def subject_setter(self, subject_setter: SubjectSetterType) -> None:
469 if isinstance(subject_setter, LegacyContact):
470 subject_setter = subject_setter.name
471 elif isinstance(subject_setter, LegacyParticipant):
472 subject_setter = subject_setter.nickname
474 if subject_setter == self.subject_setter:
475 return
476 assert isinstance(subject_setter, str | None)
477 self.stored.subject_setter = subject_setter
478 self.commit()
480 def __get_subject_setter_participant(self) -> LegacyParticipant:
481 if self.subject_setter is None:
482 return self.get_system_participant()
483 return self._participant_cls(self, Participant(nickname=self.subject_setter))
485 def features(self):
486 features = [
487 "http://jabber.org/protocol/muc",
488 "http://jabber.org/protocol/muc#stable_id",
489 "http://jabber.org/protocol/muc#self-ping-optimization",
490 "urn:xmpp:mam:2",
491 "urn:xmpp:mam:2#extended",
492 "urn:xmpp:sid:0",
493 "muc_persistent",
494 "vcard-temp",
495 "urn:xmpp:ping",
496 "urn:xmpp:occupant-id:0",
497 "jabber:iq:register",
498 self.xmpp.plugin["xep_0425"].stanza.NS,
499 ]
500 if self.type == MucType.GROUP:
501 features.extend(["muc_membersonly", "muc_nonanonymous", "muc_hidden"])
502 elif self.type == MucType.CHANNEL:
503 features.extend(["muc_open", "muc_semianonymous", "muc_public"])
504 elif self.type == MucType.CHANNEL_NON_ANONYMOUS:
505 features.extend(["muc_open", "muc_nonanonymous", "muc_public"])
506 return features
508 async def extended_features(self):
509 is_group = self.type == MucType.GROUP
511 form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
513 form.add_field(
514 "FORM_TYPE", "hidden", value="http://jabber.org/protocol/muc#roominfo"
515 )
516 form.add_field("muc#roomconfig_persistentroom", "boolean", value=True)
517 form.add_field("muc#roomconfig_changesubject", "boolean", value=False)
518 form.add_field("muc#maxhistoryfetch", value=str(self.max_history_fetch))
519 form.add_field("muc#roominfo_subjectmod", "boolean", value=False)
521 if self._ALL_INFO_FILLED_ON_STARTUP or self.stored.participants_filled:
522 with self.xmpp.store.session() as orm:
523 n = orm.scalar(
524 sa.select(sa.func.count(Participant.id)).filter_by(room=self.stored)
525 )
526 else:
527 n = self.n_participants
528 if n is not None:
529 form.add_field("muc#roominfo_occupants", value=str(n))
531 if d := self.description:
532 form.add_field("muc#roominfo_description", value=d)
534 if s := self.subject:
535 form.add_field("muc#roominfo_subject", value=s)
537 if self._set_avatar_task is not None:
538 await self._set_avatar_task
539 avatar = self.get_avatar()
540 if avatar and (h := avatar.id):
541 form.add_field(
542 "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1", value=h
543 )
544 form.add_field("muc#roominfo_avatarhash", "text-multi", value=[h])
546 form.add_field("muc#roomconfig_membersonly", "boolean", value=is_group)
547 form.add_field(
548 "muc#roomconfig_whois",
549 "list-single",
550 value="moderators" if self.is_anonymous else "anyone",
551 )
552 form.add_field("muc#roomconfig_publicroom", "boolean", value=not is_group)
553 form.add_field("muc#roomconfig_allowpm", "boolean", value=False)
555 r = [form]
557 if reaction_form := await self.restricted_emoji_extended_feature():
558 r.append(reaction_form)
560 return r
562 def shutdown(self) -> None:
563 _, user_jid = escape_nickname(self.jid, self.user_nick)
564 for user_full_jid in self.user_full_jids():
565 presence = self.xmpp.make_presence(
566 pfrom=user_jid, pto=user_full_jid, ptype="unavailable"
567 )
568 presence["muc"]["affiliation"] = "none"
569 presence["muc"]["role"] = "none"
570 presence["muc"]["status_codes"] = {110, 332}
571 presence.send()
573 def user_full_jids(self):
574 for r in self.get_user_resources():
575 j = JID(self.user_jid)
576 j.resource = r
577 yield j
579 @property
580 def user_muc_jid(self):
581 _, user_muc_jid = escape_nickname(self.jid, self.user_nick)
582 return user_muc_jid
584 async def echo(
585 self, msg: Message, legacy_msg_id: Optional[LegacyMessageType] = None
586 ) -> None:
587 origin_id = msg.get_origin_id()
589 msg.set_from(self.user_muc_jid)
590 msg.set_id(msg.get_id())
591 if origin_id:
592 # because of slixmpp internal magic, we need to do this to ensure the origin_id
593 # is present
594 set_origin_id(msg, origin_id)
595 if legacy_msg_id:
596 msg["stanza_id"]["id"] = self.session.legacy_to_xmpp_msg_id(legacy_msg_id)
597 else:
598 msg["stanza_id"]["id"] = str(uuid4())
599 msg["stanza_id"]["by"] = self.jid
600 msg["occupant-id"]["id"] = "slidge-user"
602 self.archive.add(msg, await self.get_user_participant())
604 for user_full_jid in self.user_full_jids():
605 self.log.debug("Echoing to %s", user_full_jid)
606 msg = copy(msg)
607 msg.set_to(user_full_jid)
609 msg.send()
611 def _post_avatar_update(self, cached_avatar) -> None:
612 self.__send_configuration_change((104,))
613 self._send_room_presence()
615 def _send_room_presence(self, user_full_jid: Optional[JID] = None) -> None:
616 if user_full_jid is None:
617 tos = self.user_full_jids()
618 else:
619 tos = [user_full_jid]
620 for to in tos:
621 p = self.xmpp.make_presence(pfrom=self.jid, pto=to)
622 if (avatar := self.get_avatar()) and (h := avatar.id):
623 p["vcard_temp_update"]["photo"] = h
624 else:
625 p["vcard_temp_update"]["photo"] = ""
626 p.send()
628 @timeit
629 async def join(self, join_presence: Presence):
630 user_full_jid = join_presence.get_from()
631 requested_nickname = join_presence.get_to().resource
632 client_resource = user_full_jid.resource
634 if client_resource in self.get_user_resources():
635 self.log.debug("Received join from a resource that is already joined.")
637 if not requested_nickname or not client_resource:
638 raise XMPPError("jid-malformed", by=self.jid)
640 self.add_user_resource(client_resource)
642 self.log.debug(
643 "Resource %s of %s wants to join room %s with nickname %s",
644 client_resource,
645 self.user_jid,
646 self.legacy_id,
647 requested_nickname,
648 )
650 user_nick = self.user_nick
651 user_participant = None
652 async for participant in self.get_participants():
653 if participant.is_user:
654 user_participant = participant
655 continue
656 participant.send_initial_presence(full_jid=user_full_jid)
658 if user_participant is None:
659 user_participant = await self.get_user_participant()
660 if not user_participant.is_user:
661 self.log.warning("is_user flag not set participant on user_participant")
662 user_participant.is_user = True # type:ignore
663 user_participant.send_initial_presence(
664 user_full_jid,
665 presence_id=join_presence["id"],
666 nick_change=user_nick != requested_nickname,
667 )
669 history_params = join_presence["muc_join"]["history"]
670 maxchars = int_or_none(history_params["maxchars"])
671 maxstanzas = int_or_none(history_params["maxstanzas"])
672 seconds = int_or_none(history_params["seconds"])
673 try:
674 since = self.xmpp.plugin["xep_0082"].parse(history_params["since"])
675 except ValueError:
676 since = None
677 if seconds is not None:
678 since = datetime.now() - timedelta(seconds=seconds)
679 if equals_zero(maxchars) or equals_zero(maxstanzas):
680 log.debug("Joining client does not want any old-school MUC history-on-join")
681 else:
682 self.log.debug("Old school history fill")
683 await self.__fill_history()
684 await self.__old_school_history(
685 user_full_jid,
686 maxchars=maxchars,
687 maxstanzas=maxstanzas,
688 since=since,
689 )
690 self.__get_subject_setter_participant().set_room_subject(
691 self.subject if self.HAS_SUBJECT else (self.description or self.name),
692 user_full_jid,
693 self.subject_date,
694 )
695 if t := self._set_avatar_task:
696 await t
697 self._send_room_presence(user_full_jid)
699 async def get_user_participant(self, **kwargs) -> "LegacyParticipantType":
700 """
701 Get the participant representing the gateway user
703 :param kwargs: additional parameters for the :class:`.Participant`
704 construction (optional)
705 :return:
706 """
707 p = await self.get_participant(self.user_nick, is_user=True, **kwargs)
708 self.__store_participant(p)
709 return p
711 def __store_participant(self, p: "LegacyParticipantType") -> None:
712 if self.get_lock("fill participants"):
713 return
714 p.commit(merge=True)
716 async def get_participant(
717 self,
718 nickname: str,
719 raise_if_not_found: bool = False,
720 fill_first: bool = False,
721 store: bool = True,
722 is_user: bool = False,
723 ) -> "LegacyParticipantType":
724 """
725 Get a participant by their nickname.
727 In non-anonymous groups, you probably want to use
728 :meth:`.LegacyMUC.get_participant_by_contact` instead.
730 :param nickname: Nickname of the participant (used as resource part in the MUC)
731 :param raise_if_not_found: Raise XMPPError("item-not-found") if they are not
732 in the participant list (internal use by slidge, plugins should not
733 need that)
734 :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called first
735 (internal use by slidge, plugins should not need that)
736 :param store: persistently store the user in the list of MUC participants
737 :return:
738 """
739 if fill_first:
740 await self.__fill_participants()
741 with self.xmpp.store.session() as orm:
742 stored = (
743 orm.query(Participant)
744 .filter(
745 Participant.room == self.stored,
746 (Participant.nickname == nickname)
747 | (Participant.resource == nickname),
748 )
749 .one_or_none()
750 )
751 if stored is not None:
752 return self.participant_from_store(stored)
754 if raise_if_not_found:
755 raise XMPPError("item-not-found")
756 p = self._participant_cls(
757 self, Participant(room=self.stored, nickname=nickname, is_user=is_user)
758 )
759 if store:
760 self.__store_participant(p)
761 if (
762 not self.get_lock("fill participants")
763 and not self.get_lock("fill history")
764 and self.stored.participants_filled
765 and not p.is_user
766 and not p.is_system
767 ):
768 p.send_affiliation_change()
769 return p
771 def get_system_participant(self) -> "LegacyParticipantType":
772 """
773 Get a pseudo-participant, representing the room itself
775 Can be useful for events that cannot be mapped to a participant,
776 e.g. anonymous moderation events, or announces from the legacy
777 service
778 :return:
779 """
780 return self._participant_cls(self, Participant(), is_system=True)
782 async def get_participant_by_contact(
783 self, c: "LegacyContact"
784 ) -> "LegacyParticipantType":
785 """
786 Get a non-anonymous participant.
788 This is what should be used in non-anonymous groups ideally, to ensure
789 that the Contact jid is associated to this participant
791 :param c: The :class:`.LegacyContact` instance corresponding to this contact
792 :return:
793 """
794 await self.session.contacts.ready
796 if not self.get_lock("fill participants"):
797 with self.xmpp.store.session() as orm:
798 self.stored = orm.merge(self.stored)
799 stored = (
800 orm.query(Participant)
801 .filter_by(contact=c.stored, room=self.stored)
802 .one_or_none()
803 )
804 if stored is not None:
805 return self.participant_from_store(stored=stored, contact=c)
807 nickname = c.name or unescape_node(c.jid.node)
809 if self.stored.id is None:
810 nick_available = True
811 else:
812 with self.xmpp.store.session() as orm:
813 nick_available = (
814 orm.query(Participant.id).filter_by(
815 room=self.stored, nickname=nickname
816 )
817 ).one_or_none() is None
819 if not nick_available:
820 self.log.debug("Nickname conflict")
821 nickname = f"{nickname} ({c.jid.node})"
822 p = self._participant_cls(
823 self, Participant(nickname=nickname, room=self.stored), contact=c
824 )
826 self.__store_participant(p)
827 # FIXME: this is not great but given the current design,
828 # during participants fill and history backfill we do not
829 # want to send presence, because we might :update affiliation
830 # and role afterwards.
831 # We need a refactor of the MUC class… later™
832 if (
833 self.stored.participants_filled
834 and not self.get_lock("fill participants")
835 and not self.get_lock("fill history")
836 ):
837 p.send_last_presence(force=True, no_cache_online=True)
838 return p
840 async def get_participant_by_legacy_id(
841 self, legacy_id: LegacyUserIdType
842 ) -> "LegacyParticipantType":
843 try:
844 c = await self.session.contacts.by_legacy_id(legacy_id)
845 except ContactIsUser:
846 return await self.get_user_participant()
847 return await self.get_participant_by_contact(c)
849 def remove_participant(
850 self,
851 p: "LegacyParticipantType",
852 kick: bool = False,
853 ban: bool = False,
854 reason: str | None = None,
855 ):
856 """
857 Call this when a participant leaves the room
859 :param p: The participant
860 :param kick: Whether the participant left because they were kicked
861 :param ban: Whether the participant left because they were banned
862 :param reason: Optionally, a reason why the participant was removed.
863 """
864 if kick and ban:
865 raise TypeError("Either kick or ban")
866 with self.xmpp.store.session() as orm:
867 orm.delete(p.stored)
868 orm.commit()
869 if kick:
870 codes = {307}
871 elif ban:
872 codes = {301}
873 else:
874 codes = None
875 presence = p._make_presence(ptype="unavailable", status_codes=codes)
876 p.stored.affiliation = "outcast" if ban else "none"
877 p.stored.role = "none"
878 if reason:
879 presence["muc"].set_item_attr("reason", reason)
880 p._send(presence)
882 def rename_participant(self, old_nickname: str, new_nickname: str) -> None:
883 with self.xmpp.store.session() as orm:
884 stored = (
885 orm.query(Participant)
886 .filter_by(room=self.stored, nickname=old_nickname)
887 .one_or_none()
888 )
889 if stored is None:
890 self.log.debug("Tried to rename a participant that we didn't know")
891 return
892 p = self.participant_from_store(stored)
893 if p.nickname == old_nickname:
894 p.nickname = new_nickname
896 async def __old_school_history(
897 self,
898 full_jid: JID,
899 maxchars: Optional[int] = None,
900 maxstanzas: Optional[int] = None,
901 seconds: Optional[int] = None,
902 since: Optional[datetime] = None,
903 ) -> None:
904 """
905 Old-style history join (internal slidge use)
907 :param full_jid:
908 :param maxchars:
909 :param maxstanzas:
910 :param seconds:
911 :param since:
912 :return:
913 """
914 if since is None:
915 if seconds is None:
916 start_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
917 else:
918 start_date = datetime.now(tz=timezone.utc) - timedelta(seconds=seconds)
919 else:
920 start_date = since or datetime.now(tz=timezone.utc) - timedelta(days=1)
922 for h_msg in self.archive.get_all(
923 start_date=start_date, end_date=None, last_page_n=maxstanzas
924 ):
925 msg = h_msg.stanza_component_ns
926 msg["delay"]["stamp"] = h_msg.when
927 msg.set_to(full_jid)
928 self.xmpp.send(msg, False)
930 async def send_mam(self, iq: Iq) -> None:
931 await self.__fill_history()
933 form_values = iq["mam"]["form"].get_values()
935 start_date = str_to_datetime_or_none(form_values.get("start"))
936 end_date = str_to_datetime_or_none(form_values.get("end"))
938 after_id = form_values.get("after-id")
939 before_id = form_values.get("before-id")
941 sender = form_values.get("with")
943 ids = form_values.get("ids") or ()
945 if max_str := iq["mam"]["rsm"]["max"]:
946 try:
947 max_results = int(max_str)
948 except ValueError:
949 max_results = None
950 else:
951 max_results = None
953 after_id_rsm = iq["mam"]["rsm"]["after"]
954 after_id = after_id_rsm or after_id
956 before_rsm = iq["mam"]["rsm"]["before"]
957 if before_rsm is not None and max_results is not None:
958 last_page_n = max_results
959 # - before_rsm is True means the empty element <before />, which means
960 # "last page in chronological order", cf https://xmpp.org/extensions/xep-0059.html#backwards
961 # - before_rsm == "an ID" means <before>an ID</before>
962 if before_rsm is not True:
963 before_id = before_rsm
964 else:
965 last_page_n = None
967 first = None
968 last = None
969 count = 0
971 it = self.archive.get_all(
972 start_date,
973 end_date,
974 before_id,
975 after_id,
976 ids,
977 last_page_n,
978 sender,
979 bool(iq["mam"]["flip_page"]),
980 )
982 for history_msg in it:
983 last = xmpp_id = history_msg.id
984 if first is None:
985 first = xmpp_id
987 wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from())
988 wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"]
989 wrapper_msg["mam_result"]["id"] = xmpp_id
990 wrapper_msg["mam_result"].append(history_msg.forwarded())
992 wrapper_msg.send()
993 count += 1
995 if max_results and count == max_results:
996 break
998 if max_results:
999 try:
1000 next(it)
1001 except StopIteration:
1002 complete = True
1003 else:
1004 complete = False
1005 else:
1006 complete = True
1008 reply = iq.reply()
1009 if not self.STABLE_ARCHIVE:
1010 reply["mam_fin"]["stable"] = "false"
1011 if complete:
1012 reply["mam_fin"]["complete"] = "true"
1013 reply["mam_fin"]["rsm"]["first"] = first
1014 reply["mam_fin"]["rsm"]["last"] = last
1015 reply["mam_fin"]["rsm"]["count"] = str(count)
1016 reply.send()
1018 async def send_mam_metadata(self, iq: Iq) -> None:
1019 await self.__fill_history()
1020 await self.archive.send_metadata(iq)
1022 async def kick_resource(self, r: str) -> None:
1023 """
1024 Kick a XMPP client of the user. (slidge internal use)
1026 :param r: The resource to kick
1027 """
1028 pto = JID(self.user_jid)
1029 pto.resource = r
1030 p = self.xmpp.make_presence(
1031 pfrom=(await self.get_user_participant()).jid, pto=pto
1032 )
1033 p["type"] = "unavailable"
1034 p["muc"]["affiliation"] = "none"
1035 p["muc"]["role"] = "none"
1036 p["muc"]["status_codes"] = {110, 333}
1037 p.send()
1039 async def __get_bookmark(self) -> Item | None:
1040 item = Item()
1041 item["id"] = self.jid
1043 iq = Iq(stype="get", sfrom=self.user_jid, sto=self.user_jid)
1044 iq["pubsub"]["items"]["node"] = self.xmpp["xep_0402"].stanza.NS
1045 iq["pubsub"]["items"].append(item)
1047 try:
1048 ans = await self.xmpp["xep_0356"].send_privileged_iq(iq)
1049 if len(ans["pubsub"]["items"]) != 1:
1050 return None
1051 # this below creates the item if it wasn't here already
1052 # (slixmpp annoying magic)
1053 item = ans["pubsub"]["items"]["item"]
1054 item["id"] = self.jid
1055 return item
1056 except IqTimeout as exc:
1057 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: timeout")
1058 return None
1059 except IqError as exc:
1060 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: {exc}")
1061 return None
1062 except PermissionError:
1063 warnings.warn(
1064 "IQ privileges (XEP0356) are not set, we cannot fetch the user bookmarks"
1065 )
1066 return None
1068 async def add_to_bookmarks(
1069 self,
1070 auto_join: bool = True,
1071 preserve: bool = True,
1072 pin: bool | None = None,
1073 notify: WhenLiteral | None = None,
1074 ) -> None:
1075 """
1076 Add the MUC to the user's XMPP bookmarks (:xep:`0402')
1078 This requires that slidge has the IQ privileged set correctly
1079 on the XMPP server
1081 :param auto_join: whether XMPP clients should automatically join
1082 this MUC on startup. In theory, XMPP clients will receive
1083 a "push" notification when this is called, and they will
1084 join if they are online.
1085 :param preserve: preserve auto-join and bookmarks extensions
1086 set by the user outside slidge
1087 :param pin: Pin the group chat bookmark :xep:`0469`. Requires privileged entity.
1088 If set to ``None`` (default), the bookmark pinning status will be untouched.
1089 :param notify: Chat notification setting: :xep:`0492`. Requires privileged entity.
1090 If set to ``None`` (default), the setting will be untouched. Only the "global"
1091 notification setting is supported (ie, per client type is not possible).
1092 """
1093 existing = await self.__get_bookmark() if preserve else None
1095 new = Item()
1096 new["id"] = self.jid
1097 new["conference"]["nick"] = self.user_nick
1099 if existing is None:
1100 change = True
1101 new["conference"]["autojoin"] = auto_join
1102 else:
1103 change = False
1104 new["conference"]["autojoin"] = existing["conference"]["autojoin"]
1106 existing_extensions = existing is not None and existing[
1107 "conference"
1108 ].get_plugin("extensions", check=True)
1110 # preserving extensions we don't know about is a MUST
1111 if existing_extensions:
1112 assert existing is not None
1113 for el in existing["conference"]["extensions"].xml:
1114 if el.tag.startswith(f"{{{NOTIFY_NS}}}"):
1115 if notify is not None:
1116 continue
1117 if el.tag.startswith(f"{{{PINNING_NS}}}"):
1118 if pin is not None:
1119 continue
1120 new["conference"]["extensions"].append(el)
1122 if pin is not None:
1123 if existing_extensions:
1124 assert existing is not None
1125 existing_pin = (
1126 existing["conference"]["extensions"].get_plugin(
1127 "pinned", check=True
1128 )
1129 is not None
1130 )
1131 if existing_pin != pin:
1132 change = True
1133 new["conference"]["extensions"]["pinned"] = pin
1135 if notify is not None:
1136 new["conference"]["extensions"].enable("notify")
1137 if existing_extensions:
1138 assert existing is not None
1139 existing_notify = existing["conference"]["extensions"].get_plugin(
1140 "notify", check=True
1141 )
1142 if existing_notify is None:
1143 change = True
1144 else:
1145 if existing_notify.get_config() != notify:
1146 change = True
1147 for el in existing_notify:
1148 new["conference"]["extensions"]["notify"].append(el)
1149 new["conference"]["extensions"]["notify"].configure(notify)
1151 if change:
1152 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid)
1153 iq["pubsub"]["publish"]["node"] = self.xmpp["xep_0402"].stanza.NS
1154 iq["pubsub"]["publish"].append(new)
1156 iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS
1158 try:
1159 await self.xmpp["xep_0356"].send_privileged_iq(iq)
1160 except PermissionError:
1161 warnings.warn(
1162 "IQ privileges (XEP0356) are not set, we cannot add bookmarks for the user"
1163 )
1164 # fallback by forcing invitation
1165 bookmark_add_fail = True
1166 except IqError as e:
1167 warnings.warn(
1168 f"Something went wrong while trying to set the bookmarks: {e}"
1169 )
1170 # fallback by forcing invitation
1171 bookmark_add_fail = True
1172 else:
1173 bookmark_add_fail = False
1174 else:
1175 self.log.debug("Bookmark does not need updating.")
1176 return
1178 if bookmark_add_fail:
1179 self.session.send_gateway_invite(
1180 self,
1181 reason="This group could not be added automatically for you, most"
1182 "likely because this gateway is not configured as a privileged entity. "
1183 "Contact your administrator.",
1184 )
1185 elif existing is None and self.session.user.preferences.get(
1186 "always_invite_when_adding_bookmarks", True
1187 ):
1188 self.session.send_gateway_invite(
1189 self,
1190 reason="The gateway is configured to always send invitations for groups.",
1191 )
1193 async def on_avatar(
1194 self, data: Optional[bytes], mime: Optional[str]
1195 ) -> Optional[Union[int, str]]:
1196 """
1197 Called when the user tries to set the avatar of the room from an XMPP
1198 client.
1200 If the set avatar operation is completed, should return a legacy image
1201 unique identifier. In this case the MUC avatar will be immediately
1202 updated on the XMPP side.
1204 If data is not None and this method returns None, then we assume that
1205 self.set_avatar() will be called elsewhere, eg triggered by a legacy
1206 room update event.
1208 :param data: image data or None if the user meant to remove the avatar
1209 :param mime: the mime type of the image. Since this is provided by
1210 the XMPP client, there is no guarantee that this is valid or
1211 correct.
1212 :return: A unique avatar identifier, which will trigger
1213 :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if
1214 :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else.
1215 """
1216 raise NotImplementedError
1218 admin_set_avatar = deprecated("LegacyMUC.on_avatar", on_avatar)
1220 async def on_set_affiliation(
1221 self,
1222 contact: "LegacyContact",
1223 affiliation: MucAffiliation,
1224 reason: Optional[str],
1225 nickname: Optional[str],
1226 ):
1227 """
1228 Triggered when the user requests changing the affiliation of a contact
1229 for this group.
1231 Examples: promotion them to moderator, ban (affiliation=outcast).
1233 :param contact: The contact whose affiliation change is requested
1234 :param affiliation: The new affiliation
1235 :param reason: A reason for this affiliation change
1236 :param nickname:
1237 """
1238 raise NotImplementedError
1240 async def on_kick(self, contact: "LegacyContact", reason: Optional[str]):
1241 """
1242 Triggered when the user requests changing the role of a contact
1243 to "none" for this group. Action commonly known as "kick".
1245 :param contact: Contact to be kicked
1246 :param reason: A reason for this kick
1247 """
1248 raise NotImplementedError
1250 async def on_set_config(
1251 self,
1252 name: Optional[str],
1253 description: Optional[str],
1254 ):
1255 """
1256 Triggered when the user requests changing the room configuration.
1257 Only title and description can be changed at the moment.
1259 The legacy module is responsible for updating :attr:`.title` and/or
1260 :attr:`.description` of this instance.
1262 If :attr:`.HAS_DESCRIPTION` is set to False, description will always
1263 be ``None``.
1265 :param name: The new name of the room.
1266 :param description: The new description of the room.
1267 """
1268 raise NotImplementedError
1270 async def on_destroy_request(self, reason: Optional[str]):
1271 """
1272 Triggered when the user requests room destruction.
1274 :param reason: Optionally, a reason for the destruction
1275 """
1276 raise NotImplementedError
1278 async def parse_mentions(self, text: str) -> list[Mention]:
1279 with self.xmpp.store.session() as orm:
1280 await self.__fill_participants()
1281 orm.add(self.stored)
1282 participants = {p.nickname: p for p in self.stored.participants}
1284 if len(participants) == 0:
1285 return []
1287 result = []
1288 for match in re.finditer(
1289 "|".join(
1290 sorted(
1291 [re.escape(nick) for nick in participants.keys()],
1292 key=lambda nick: len(nick),
1293 reverse=True,
1294 )
1295 ),
1296 text,
1297 ):
1298 span = match.span()
1299 nick = match.group()
1300 if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION:
1301 continue
1302 if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION:
1303 participant = self.participant_from_store(
1304 stored=participants[nick],
1305 )
1306 if contact := participant.contact:
1307 result.append(
1308 Mention(contact=contact, start=span[0], end=span[1])
1309 )
1310 return result
1312 async def on_set_subject(self, subject: str) -> None:
1313 """
1314 Triggered when the user requests changing the room subject.
1316 The legacy module is responsible for updating :attr:`.subject` of this
1317 instance.
1319 :param subject: The new subject for this room.
1320 """
1321 raise NotImplementedError
1323 @property
1324 def participants_filled(self) -> bool:
1325 return self.stored.participants_filled
1328def set_origin_id(msg: Message, origin_id: str) -> None:
1329 sub = ET.Element("{urn:xmpp:sid:0}origin-id")
1330 sub.attrib["id"] = origin_id
1331 msg.xml.append(sub)
1334def int_or_none(x):
1335 try:
1336 return int(x)
1337 except ValueError:
1338 return None
1341def equals_zero(x):
1342 if x is None:
1343 return False
1344 else:
1345 return x == 0
1348def str_to_datetime_or_none(date: Optional[str]):
1349 if date is None:
1350 return
1351 try:
1352 return str_to_datetime(date)
1353 except ValueError:
1354 return None
1357def bookmarks_form():
1358 form = Form()
1359 form["type"] = "submit"
1360 form.add_field(
1361 "FORM_TYPE",
1362 value="http://jabber.org/protocol/pubsub#publish-options",
1363 ftype="hidden",
1364 )
1365 form.add_field("pubsub#persist_items", value="1")
1366 form.add_field("pubsub#max_items", value="max")
1367 form.add_field("pubsub#send_last_published_item", value="never")
1368 form.add_field("pubsub#access_model", value="whitelist")
1369 return form
1372_BOOKMARKS_OPTIONS = bookmarks_form()
1373_WHITESPACE_OR_PUNCTUATION = string.whitespace + string.punctuation
1375log = logging.getLogger(__name__)