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