Coverage for slidge/group/room.py: 88%
921 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-18 04:30 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-18 04:30 +0000
1import hashlib
2import json
3import logging
4import re
5import string
6import uuid
7import warnings
8from asyncio import Lock
9from collections.abc import AsyncIterator, Iterable, Iterator
10from contextlib import asynccontextmanager
11from copy import copy
12from datetime import UTC, datetime, timedelta
13from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload
15import sqlalchemy as sa
16from slixmpp import JID, Iq, Message, Presence
17from slixmpp.exceptions import IqError, IqTimeout, XMPPError
18from slixmpp.plugins.xep_0004.stanza.form import Form
19from slixmpp.plugins.xep_0045.stanza import MUCUserItem
20from slixmpp.plugins.xep_0060.stanza import Item
21from slixmpp.plugins.xep_0082 import parse as str_to_datetime
22from slixmpp.plugins.xep_0469.stanza import NS as PINNING_NS
23from slixmpp.plugins.xep_0492.stanza import NS as NOTIFY_NS
24from slixmpp.plugins.xep_0492.stanza import WhenLiteral
25from slixmpp.xmlstream import ET
26from sqlalchemy.exc import IntegrityError
27from sqlalchemy.orm import Session as OrmSession
28from sqlalchemy.orm.exc import DetachedInstanceError
30from ..contact.contact import LegacyContact
31from ..contact.roster import ContactIsUser
32from ..core.mixins.avatar import AvatarMixin
33from ..core.mixins.base import SessionBound
34from ..core.mixins.disco import ChatterDiscoMixin
35from ..core.mixins.recipient import RecipientMixin
36from ..db.models import Participant, Room
37from ..util.archive_msg import HistoryMessage
38from ..util.jid_escaping import unescape_node
39from ..util.types import (
40 AnyParticipant,
41 AnySession,
42 HoleBound,
43 LegacyParticipantType,
44 Mention,
45 MucAffiliation,
46 MUCMessage,
47 MUCSticker,
48 MucType,
49)
50from ..util.util import derive_wired_class, timeit
51from .archive import MessageArchive
52from .participant import LegacyParticipant, escape_nickname
54if TYPE_CHECKING:
55 from ..command.base import MUCCommand
56 from ..db.avatar import CachedAvatar
58ADMIN_NS = "http://jabber.org/protocol/muc#admin"
60type SubjectSetterType = "str | LegacyContact | AnyParticipant | None"
63class LegacyMUC[LegacyParticipantType: AnyParticipant](
64 AvatarMixin,
65 ChatterDiscoMixin,
66 RecipientMixin,
67 SessionBound,
68):
69 """
70 A room, a.k.a. a Multi-User Chat.
72 MUC instances are obtained by calling :py:meth:`slidge.group.bookmarks.LegacyBookmarks`
73 on the user's :py:class:`slidge.core.session.BaseSession`.
74 """
76 max_history_fetch = 100
78 is_group: Literal[True] = True
80 DISCO_TYPE = "text"
81 DISCO_CATEGORY = "conference"
83 STABLE_ARCHIVE = False
84 """
85 Because legacy events like reactions, editions, etc. don't all map to a stanza
86 with a proper legacy ID, slidge usually cannot guarantee the stability of the archive
87 across restarts.
89 Set this to True if you know what you're doing, but realistically, this can't
90 be set to True until archive is permanently stored on disk by slidge.
92 This is just a flag on archive responses that most clients ignore anyway.
93 """
95 """
96 Set this to true if the fill_participants() / fill_participants() design does not
97 fit the legacy API, ie, no lazy loading of the participant list and history.
98 """
100 HAS_DESCRIPTION = True
101 """
102 Set this to false if the legacy network does not allow setting a description
103 for the group. In this case the description field will not be present in the
104 room configuration form.
105 """
107 HAS_SUBJECT = True
108 """
109 Set this to false if the legacy network does not allow setting a subject
110 (sometimes also called topic) for the group. In this case, as a subject is
111 recommended by :xep:`0045` ("SHALL"), the description (or the group name as
112 ultimate fallback) will be used as the room subject.
113 By setting this to false, an error will be returned when the :term:`User`
114 tries to set the room subject.
115 """
117 archive: MessageArchive
119 stored: Room
121 commands: ClassVar[dict[str, "type[MUCCommand[Any]]"]] = {}
122 commands_chat: ClassVar[dict[str, "type[MUCCommand[Any]]"]] = {}
124 participant_cls: type[LegacyParticipantType]
125 """
126 The concrete :class:`.LegacyParticipant` subclass this MUC produces.
128 Derived automatically from the generic parameter, e.g.,
129 ``class MUC(LegacyMUC[Participant])`` produces ``Participant`` instances.
130 """
132 is_participant: Literal[False] = False
134 def __init_subclass__(cls, **kwargs: object) -> None:
135 super().__init_subclass__(**kwargs)
136 derive_wired_class(cls, LegacyMUC, "participant_cls")
138 def __init__(self, session: AnySession, stored: Room) -> None:
139 self.session = session
140 self.xmpp = session.xmpp
141 self.stored = stored
142 self._set_logger()
143 super().__init__()
145 self.archive = MessageArchive(stored, self.xmpp.store)
147 async def on_message(
148 self, message: MUCMessage[LegacyParticipantType]
149 ) -> str | None:
150 """
151 Triggered when the user sends a message to this :term:`MUC`.
153 :return: A message ID of that can be used later to further reference
154 this message (reactions, read marks, etc.).
155 """
156 raise NotImplementedError
158 async def on_sticker(self, sticker: MUCSticker) -> str | None:
159 """
160 Triggered when the user sends a sticker to this :term:`MUC`.
162 :param sticker: The sticker sent by the user.
164 :return: A message ID of that can be used later to further reference
165 this message (reactions, read marks, etc.).
166 """
167 raise NotImplementedError
169 def pop_unread_xmpp_ids_up_to(self, horizon_xmpp_id: str) -> list[str]:
170 """
171 Return XMPP msg ids sent in this group up to a given XMPP msg id.
173 Plugins have no reason to use this, but it is used by slidge core
174 for legacy networks that need to mark *all* messages as read (most XMPP
175 clients only send a read marker for the latest message).
177 This has side effects: all messages up to the horizon XMPP id will be marked
178 as read in the DB. If the horizon XMPP id is not found, all messages of this
179 MUC will be marked as read.
181 :param horizon_xmpp_id: The latest message
182 :return: A list of XMPP ids if horizon_xmpp_id was not found
183 """
184 with self.xmpp.store.session() as orm:
185 assert self.stored.id is not None
186 ids = self.xmpp.store.mam.pop_unread_up_to(
187 orm, self.stored.id, horizon_xmpp_id
188 )
189 orm.commit()
190 return ids
192 def participant_from_store(
193 self, stored: Participant, contact: LegacyContact | None = None
194 ) -> LegacyParticipantType:
195 if contact is None and stored.contact is not None:
196 contact = self.session.contacts.from_store(stored.contact)
197 return self.participant_cls(self, stored=stored, contact=contact)
199 @property
200 def jid(self) -> JID:
201 return self.stored.jid
203 @jid.setter
204 def jid(self, x: JID) -> None:
205 # FIXME: without this, mypy yields
206 # "Cannot override writeable attribute with read-only property"
207 # But it does not happen for LegacyContact. WTF?
208 raise RuntimeError
210 @property
211 def legacy_id(self) -> str:
212 return self.stored.legacy_id
214 @property
215 def space_legacy_id(self) -> str:
216 return self.stored.space.legacy_id
218 @space_legacy_id.setter
219 def space_legacy_id(self, legacy_id: str) -> None:
220 if (
221 self.stored
222 and self.stored.space
223 and self.stored.space.legacy_id == legacy_id
224 ):
225 return
226 with self.orm(expire_on_commit=False) as orm:
227 space = self.xmpp.store.spaces.add_or_get(orm, self.user_pk, str(legacy_id))
228 self.stored.space = space
229 if self._updating_info:
230 with orm.no_autoflush:
231 self.stored = orm.merge(self.stored)
232 return
233 orm.add(self.stored)
234 orm.commit()
236 def orm(
237 self,
238 **kwargs: Any, # noqa:ANN401
239 ) -> OrmSession:
240 return self.xmpp.store.session(**kwargs)
242 @property
243 def type(self) -> MucType:
244 return self.stored.muc_type
246 @type.setter
247 def type(self, type_: MucType) -> None:
248 if self.type == type_:
249 return
250 self.update_stored_attribute(muc_type=type_)
252 @property
253 def n_participants(self) -> int | None:
254 return self.stored.n_participants
256 @n_participants.setter
257 def n_participants(self, n_participants: int | None) -> None:
258 if self.stored.n_participants == n_participants:
259 return
260 self.update_stored_attribute(n_participants=n_participants)
262 def _set_logger(self) -> None:
263 self.log = logging.getLogger(f"{self.user_jid}:muc:{self}")
265 def __repr__(self) -> str:
266 return f"<MUC #{self.stored.id} '{self.name}' ({self.stored.legacy_id} - {self.jid.user})'>"
268 @property
269 def subject_date(self) -> datetime | None:
270 if self.stored.subject_date is None:
271 return None
272 return self.stored.subject_date.replace(tzinfo=UTC)
274 @subject_date.setter
275 def subject_date(self, when: datetime | None) -> None:
276 if self.subject_date == when:
277 return
278 self.update_stored_attribute(subject_date=when)
280 def __send_configuration_change(self, codes: tuple[int, ...]) -> None:
281 part = self.get_system_participant()
282 part.send_configuration_change(codes)
284 @property
285 def user_nick(self) -> str:
286 return (
287 self.stored.user_nick
288 or self.session.bookmarks.user_nick
289 or self.user_jid.node
290 )
292 @user_nick.setter
293 def user_nick(self, nick: str) -> None:
294 if nick == self.user_nick:
295 return
296 self.update_stored_attribute(user_nick=nick)
298 def add_user_resource(self, resource: str) -> None:
299 stored_set = self.get_user_resources()
300 if resource in stored_set:
301 return
302 stored_set.add(resource)
303 self.update_stored_attribute(
304 user_resources=(json.dumps(list(stored_set)) if stored_set else None)
305 )
307 def get_user_resources(self) -> set[str]:
308 stored_str = self.stored.user_resources
309 if stored_str is None:
310 return set()
311 return set(json.loads(stored_str))
313 def remove_user_resource(self, resource: str) -> None:
314 stored_set = self.get_user_resources()
315 if resource not in stored_set:
316 return
317 stored_set.remove(resource)
318 self.update_stored_attribute(
319 user_resources=(json.dumps(list(stored_set)) if stored_set else None)
320 )
322 @asynccontextmanager
323 async def lock(self, id_: str) -> AsyncIterator[None]:
324 async with self.session.lock((self.legacy_id, id_)):
325 yield
327 def get_lock(self, id_: str) -> Lock | None:
328 return self.session.get_lock((self.legacy_id, id_))
330 async def __fill_participants(self) -> None:
331 if self.participants_filled:
332 return
334 async with self.lock("fill participants"):
335 with self.xmpp.store.session(expire_on_commit=False) as orm:
336 orm.add(self.stored)
337 with orm.no_autoflush:
338 orm.refresh(self.stored, ["participants_filled"])
339 if self.participants_filled:
340 return
341 parts: list[Participant] = []
342 resources = set[str]()
343 # During fill_participants(), self.get_participant*() methods may
344 # return a participant with a conflicting nick/resource.
345 user_found = False
346 async for participant in self.fill_participants():
347 if participant.is_user:
348 user_found = True
349 if participant.stored.resource in resources:
350 self.log.debug(
351 "Participant '%s' was yielded more than once by fill_participants(), ignoring",
352 participant.stored.resource,
353 )
354 continue
355 parts.append(participant.stored)
356 resources.add(participant.stored.resource)
358 if not user_found:
359 participant = await self.get_user_participant()
360 if participant.stored.resource in resources:
361 for p in parts:
362 if p.resource == participant.jid.resource:
363 p.is_user = True
364 else:
365 parts.append(participant.stored)
366 resources.add(participant.stored.resource)
368 with self.xmpp.store.session(expire_on_commit=False) as orm:
369 orm.add(self.stored)
370 # because self.fill_participants() is async, self.stored may be stale at
371 # this point, and the only thing we want to update is the participant list
372 # and the participant_filled attribute.
373 with orm.no_autoflush:
374 orm.refresh(self.stored, ["participants"])
375 for part in parts:
376 orm.merge(part)
377 self.stored.participants_filled = True
378 orm.commit()
380 async def get_participants(
381 self, affiliation: MucAffiliation | None = None
382 ) -> AsyncIterator[LegacyParticipantType]:
383 await self.__fill_participants()
384 with self.xmpp.store.session(expire_on_commit=False, autoflush=False) as orm:
385 self.stored = orm.merge(self.stored)
386 db_participants = self.stored.participants
387 for db_participant in db_participants:
388 if affiliation is not None and db_participant.affiliation != affiliation:
389 continue
390 yield self.participant_from_store(db_participant)
392 async def __fill_history(self) -> None:
393 async with self.lock("fill history"):
394 with self.xmpp.store.session(expire_on_commit=False) as orm:
395 orm.add(self.stored)
396 with orm.no_autoflush:
397 orm.refresh(self.stored, ["history_filled"])
398 if self.stored.history_filled:
399 self.log.debug("History has already been fetched.")
400 return
401 log.debug("Fetching history for %s", self)
402 try:
403 before, after = self.archive.get_hole_bounds()
404 if before is not None:
405 before = before._replace(id=before.id)
406 if after is not None:
407 after = after._replace(id=after.id)
408 await self.backfill(before, after)
409 except NotImplementedError:
410 return
411 except Exception as e:
412 self.log.exception("Could not backfill", exc_info=e)
414 self.stored.history_filled = True
415 self.commit()
417 def _get_disco_name(self) -> str | None:
418 return self.name
420 @property
421 def name(self) -> str | None:
422 return self.stored.name
424 @name.setter
425 def name(self, n: str | None) -> None:
426 if self.name == n:
427 return
428 self.update_stored_attribute(name=n)
429 self._set_logger()
430 self.__send_configuration_change((104,))
432 @property
433 def description(self) -> str:
434 return self.stored.description or ""
436 @description.setter
437 def description(self, d: str) -> None:
438 if self.description == d:
439 return
440 self.update_stored_attribute(description=d)
441 self.__send_configuration_change((104,))
443 def on_presence_unavailable(self, p: Presence) -> None:
444 pto = p.get_to()
445 if pto.bare != self.jid.bare:
446 return
448 pfrom = p.get_from()
449 if pfrom.bare != self.user_jid.bare:
450 return
451 if (resource := pfrom.resource) in self.get_user_resources():
452 if pto.resource != self.user_nick:
453 self.log.debug(
454 "Received 'leave group' request but with wrong nickname. %s", p
455 )
456 self.remove_user_resource(resource)
457 else:
458 self.log.debug(
459 "Received 'leave group' request but resource was not listed. %s", p
460 )
462 async def update_info(self) -> None:
463 """
464 Fetch information about this group from the legacy network
466 This is awaited on MUC instantiation, and should be overridden to
467 update the attributes of the group chat, like title, subject, number
468 of participants etc.
470 To take advantage of the slidge avatar cache, you can check the .avatar
471 property to retrieve the "legacy file ID" of the cached avatar. If there
472 is no change, you should not call
473 :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar()` or
474 attempt to modify
475 the :attr:.avatar property.
476 """
477 raise NotImplementedError
479 async def backfill(
480 self,
481 after: HoleBound | None = None,
482 before: HoleBound | None = None,
483 ) -> None:
484 """
485 Override this if the legacy network provide server-side group archives.
487 In it, send history messages using ``self.get_participant(xxx).send_xxxx``,
488 with the ``archive_only=True`` kwarg. This is only called once per slidge
489 run for a given group.
491 :param after: Fetch messages after this one.
492 If ``None``, slidge's local archive was empty before start-up,
493 ie, no history was ever fetched for this room since the user registered.
494 It's up to gateway implementations to decide how far to fetch messages before
495 the user registered.
496 If not ``None``, slidge has some messages in this archive, and
497 the gateway shall try to fetch history up to (and excluding) this message
498 to avoid "holes" in the history of this group.
499 :param before: Fetch messages before this one.
500 If ``None``, the gateway shall fetch all messages up to the most recent one.
501 If not ``None``, slidge has already archived some live messages
502 it received during its lifetime, and there is no need to query the legacy
503 network for any message after (and including) this one.
504 """
505 raise NotImplementedError
507 async def fill_participants(self) -> AsyncIterator[LegacyParticipantType]:
508 """
509 This method should yield the list of all members of this group.
511 Typically, use ``participant = self.get_participant()``, self.get_participant_by_contact(),
512 of self.get_user_participant(), and update their affiliation, hats, etc.
513 before yielding them.
514 """
515 return
516 yield
518 @property
519 def subject(self) -> str:
520 return self.stored.subject or ""
522 @subject.setter
523 def subject(self, s: str) -> None:
524 if s == self.subject:
525 return
527 self.update_stored_attribute(subject=s)
528 self.__get_subject_setter_participant().set_room_subject(
529 s, None, self.subject_date, False
530 )
532 @property
533 def is_anonymous(self) -> bool:
534 return self.type == MucType.CHANNEL
536 @property
537 def subject_setter(self) -> str | None:
538 return self.stored.subject_setter
540 @subject_setter.setter
541 def subject_setter(self, subject_setter: SubjectSetterType) -> None:
542 if isinstance(subject_setter, LegacyContact):
543 subject_setter = subject_setter.name
544 elif isinstance(subject_setter, LegacyParticipant):
545 subject_setter = subject_setter.nickname
547 if subject_setter == self.subject_setter:
548 return
549 assert isinstance(subject_setter, str | None)
550 self.update_stored_attribute(subject_setter=subject_setter)
552 def __get_subject_setter_participant(self) -> AnyParticipant:
553 if self.subject_setter is None:
554 return self.get_system_participant()
555 return self.participant_cls(
556 self,
557 Participant(nickname=self.subject_setter, occupant_id="subject-setter"),
558 )
560 def features(self) -> list[str]:
561 features = [
562 "http://jabber.org/protocol/muc",
563 "http://jabber.org/protocol/muc#stable_id",
564 "http://jabber.org/protocol/muc#self-ping-optimization",
565 "urn:xmpp:mam:2",
566 "urn:xmpp:mam:2#extended",
567 "urn:xmpp:sid:0",
568 "muc_persistent",
569 "vcard-temp",
570 "urn:xmpp:ping",
571 "urn:xmpp:occupant-id:0",
572 "jabber:iq:register",
573 "http://jabber.org/protocol/commands",
574 "urn:xmpp:muc:affiliations:1",
575 self.xmpp.plugin["xep_0425"].stanza.NS,
576 ]
577 if self.type == MucType.GROUP:
578 features.extend(["muc_membersonly", "muc_nonanonymous", "muc_hidden"])
579 elif self.type == MucType.CHANNEL:
580 features.extend(["muc_open", "muc_semianonymous", "muc_public"])
581 elif self.type == MucType.CHANNEL_NON_ANONYMOUS:
582 features.extend(["muc_open", "muc_nonanonymous", "muc_public"])
584 try: # oh boy, this sucks
585 has_space = self.stored.space is not None
586 except DetachedInstanceError:
587 self.refresh()
588 has_space = self.stored.space is not None
590 if has_space:
591 features.append("urn:xmpp:spaces:0")
592 return features
594 async def extended_features(self) -> list[Form]:
595 is_group = self.type == MucType.GROUP
597 form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
599 form.add_field(
600 "FORM_TYPE", "hidden", value="http://jabber.org/protocol/muc#roominfo"
601 )
602 form.add_field("muc#roomconfig_persistentroom", "boolean", value=True)
603 form.add_field("muc#roomconfig_changesubject", "boolean", value=False)
604 form.add_field("muc#maxhistoryfetch", value=str(self.max_history_fetch))
605 form.add_field("muc#roominfo_subjectmod", "boolean", value=False)
607 if self.stored.id is not None and self.participants_filled:
608 with self.xmpp.store.session() as orm:
609 n = orm.scalar(
610 sa.select(sa.func.count(Participant.id)).filter_by(
611 room_id=self.stored.id
612 )
613 )
614 else:
615 n = self.n_participants
616 if n is not None:
617 form.add_field("muc#roominfo_occupants", value=str(n))
619 if d := self.description:
620 form.add_field("muc#roominfo_description", value=d)
622 if s := self.subject:
623 form.add_field("muc#roominfo_subject", value=s)
625 if name := self.name:
626 form.add_field("muc#roomconfig_roomname", value=name)
628 if self._set_avatar_task is not None:
629 await self._set_avatar_task
630 avatar = self.get_avatar()
631 if avatar and (h := avatar.id):
632 form.add_field(
633 "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1", value=h
634 )
635 form.add_field("muc#roominfo_avatarhash", "text-multi", value=[h])
637 form.add_field("muc#roomconfig_membersonly", "boolean", value=is_group)
638 form.add_field(
639 "muc#roomconfig_whois",
640 "list-single",
641 value="moderators" if self.is_anonymous else "anyone",
642 )
643 form.add_field("muc#roomconfig_publicroom", "boolean", value=not is_group)
644 form.add_field("muc#roomconfig_allowpm", "boolean", value=False)
646 r = [form]
648 if reaction_form := await self.restricted_emoji_extended_feature():
649 r.append(reaction_form)
651 if self.stored.space is not None:
652 node = await self.session.bookmarks.space_legacy_id_to_node(
653 self.stored.space.legacy_id
654 )
655 iri = f"xmpp:{self.xmpp.boundjid.bare}?node={node}"
656 form.add_field("muc#roominfo_pubsub", value=iri)
657 space_form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
658 space_form.add_field("FORM_TYPE", "hidden", value="urn:xmpp:spaces:0")
659 space_form.add_field("parent", label="Space parent", value=iri)
661 return r
663 def shutdown(self) -> None:
664 _, user_jid = escape_nickname(self.jid, self.user_nick)
665 for user_full_jid in self.user_full_jids():
666 presence = self.xmpp.make_presence(
667 pfrom=user_jid, pto=user_full_jid, ptype="unavailable"
668 )
669 presence["muc"]["affiliation"] = "none"
670 presence["muc"]["role"] = "none"
671 presence["muc"]["status_codes"] = {110, 332}
672 presence.send()
674 def user_full_jids(self) -> Iterable[JID]:
675 for r in self.get_user_resources():
676 j = JID(self.user_jid)
677 j.resource = r
678 yield j
680 @property
681 def user_muc_jid(self) -> JID:
682 _, user_muc_jid = escape_nickname(self.jid, self.user_nick)
683 return user_muc_jid
685 async def echo(self, msg: Message, legacy_msg_id: str | None = None) -> str:
686 msg.set_from(self.user_muc_jid)
687 if legacy_msg_id:
688 msg["stanza_id"]["id"] = legacy_msg_id
689 else:
690 msg["stanza_id"]["id"] = str(uuid.uuid4())
691 msg["stanza_id"]["by"] = self.jid
693 user_part = await self.get_user_participant()
694 msg["occupant-id"]["id"] = user_part.stored.occupant_id
696 self.archive.add(msg, user_part)
698 for user_full_jid in self.user_full_jids():
699 self.log.debug("Echoing to %s", user_full_jid)
700 msg = copy(msg)
701 msg.set_to(user_full_jid)
703 msg.send()
705 return msg["stanza_id"]["id"] # type:ignore[no-any-return]
707 def _post_avatar_update(self, cached_avatar: "CachedAvatar | None") -> None:
708 self.__send_configuration_change((104,))
709 self._send_room_presence()
711 def _send_room_presence(self, user_full_jid: JID | None = None) -> None:
712 tos = self.user_full_jids() if user_full_jid is None else [user_full_jid]
713 for to in tos:
714 p = self.xmpp.make_presence(pfrom=self.jid, pto=to)
715 if (avatar := self.get_avatar()) and (h := avatar.id):
716 p["vcard_temp_update"]["photo"] = h
717 else:
718 p["vcard_temp_update"]["photo"] = ""
719 p.send()
721 @timeit
722 async def join(self, join_presence: Presence) -> None:
723 user_full_jid = join_presence.get_from()
724 requested_nickname = join_presence.get_to().resource
725 client_resource = user_full_jid.resource
727 if client_resource in self.get_user_resources():
728 self.log.debug("Received join from a resource that is already joined.")
730 if not requested_nickname or not client_resource:
731 raise XMPPError("jid-malformed", by=self.jid)
733 self.add_user_resource(client_resource)
735 self.log.debug(
736 "Resource %s of %s wants to join room %s with nickname %s",
737 client_resource,
738 self.user_jid,
739 self.legacy_id,
740 requested_nickname,
741 )
743 user_nick = self.user_nick
744 user_participant = None
745 await self.__fill_participants()
746 if "mav" in join_presence["muc_join"]:
747 mav_until = await self.__get_mav()
748 self.log.debug("client uses MUC affiliation versioning")
749 if join_presence["muc_join"]["mav"]["since"] != mav_until:
750 self.log.debug(
751 "client mav: %s vs our mav: %s",
752 join_presence["muc_join"]["mav"]["since"],
753 mav_until,
754 )
755 await self.__send_mav(user_full_jid, mav_until)
756 else:
757 mav_until = None
758 async for participant in self.get_participants():
759 if participant.is_user:
760 user_participant = participant
761 continue
762 participant.send_initial_presence(full_jid=user_full_jid)
764 if user_participant is None:
765 user_participant = await self.get_user_participant()
766 with self.xmpp.store.session() as orm:
767 orm.add(self.stored)
768 with orm.no_autoflush:
769 orm.refresh(self.stored, ["participants"])
770 if not user_participant.is_user:
771 self.log.warning("is_user flag not set on user_participant")
772 user_participant.is_user = True
773 user_participant.send_initial_presence(
774 user_full_jid,
775 presence_id=join_presence["id"],
776 nick_change=user_nick != requested_nickname,
777 mav_until=mav_until,
778 )
780 history_params = join_presence["muc_join"]["history"]
781 maxchars = int_or_none(history_params["maxchars"])
782 maxstanzas = int_or_none(history_params["maxstanzas"])
783 seconds = int_or_none(history_params["seconds"])
784 try:
785 since = self.xmpp.plugin["xep_0082"].parse(history_params["since"])
786 except ValueError:
787 since = None
788 if seconds is not None:
789 since = datetime.now(tz=UTC) - timedelta(seconds=seconds)
790 if equals_zero(maxchars) or equals_zero(maxstanzas):
791 log.debug("Joining client does not want any old-school MUC history-on-join")
792 else:
793 self.log.debug("Old school history fill")
794 await self.__fill_history()
795 await self.__old_school_history(
796 user_full_jid,
797 maxchars=maxchars,
798 maxstanzas=maxstanzas,
799 since=since,
800 )
801 if self.HAS_SUBJECT:
802 subject = self.subject or ""
803 else:
804 subject = self.description or self.name or ""
805 self.__get_subject_setter_participant().set_room_subject(
806 subject,
807 user_full_jid,
808 self.subject_date,
809 )
810 if t := self._set_avatar_task:
811 await t
812 self._send_room_presence(user_full_jid)
814 async def __get_mav(self) -> str:
815 data = self.__get_mav_data()
816 return self.__compute_mav_ver(data)
818 def __get_mav_data(self) -> list[tuple[str, MucAffiliation]]:
819 data: list[tuple[str, MucAffiliation]] = []
820 with self.orm(expire_on_commit=False) as orm:
821 self.stored = orm.merge(self.stored)
822 for part in self.stored.participants:
823 if not (part.is_user or part.contact):
824 continue
825 if part.affiliation == "none":
826 continue
827 data.append((self.__get_part_mav_id(part), part.affiliation))
828 return data
830 def __get_part_mav_id(self, part: "LegacyParticipantType | Participant") -> str:
831 return str(self.user_jid if part.is_user else part.contact.legacy_id) # type:ignore[union-attr] # ty:ignore[unresolved-attribute]
833 def __compute_mav_ver(self, data: list[tuple[Any, MucAffiliation]]) -> str:
834 self.log.debug("MAV data: %s", data)
835 affs = []
836 for id_, aff in data:
837 if aff == "none":
838 continue
839 affs.append(f"{id_}\0{aff}".encode())
840 affs.sort()
841 return hashlib.sha256(b"\0".join(affs)).hexdigest()
843 async def __send_mav(self, full_jid: JID, until: str) -> None:
844 msg = self.xmpp.make_message(mto=full_jid, mfrom=self.jid)
845 msg["muc"]["mav"]["until"] = until
846 with self.orm(expire_on_commit=False) as orm:
847 self.stored = orm.merge(self.stored)
848 for part in self.stored.participants:
849 if not (part.is_user or part.contact):
850 continue
851 item = MUCUserItem()
852 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare # type:ignore[union-attr]
853 item["affiliation"] = part.affiliation
854 msg["muc"].append(item)
855 msg.send()
857 async def get_user_participant(
858 self,
859 *,
860 fill_first: bool = False,
861 store: bool = True,
862 occupant_id: str | None = None,
863 ) -> "LegacyParticipantType":
864 """
865 Get the participant representing the gateway user
867 :param kwargs: additional parameters for the :class:`.Participant`
868 construction (optional)
869 :return:
870 """
871 p = await self.get_participant(
872 self.user_nick,
873 is_user=True,
874 fill_first=fill_first,
875 store=store,
876 occupant_id=occupant_id,
877 )
878 self.__store_participant(p)
879 return p
881 def __store_participant(self, p: "LegacyParticipantType") -> None:
882 if self.get_lock("fill participants"):
883 return
884 try:
885 p.commit()
886 except IntegrityError as e:
887 log.debug("Could not store participant: %r", e)
888 self.stored = p.stored.room
890 @overload
891 async def get_participant(self, nickname: str) -> "LegacyParticipantType": ...
893 @overload
894 async def get_participant(
895 self, nickname: str, *, store: bool
896 ) -> "LegacyParticipantType": ...
898 @overload
899 async def get_participant(self, *, occupant_id: str) -> "LegacyParticipantType": ...
901 @overload
902 async def get_participant(
903 self, *, occupant_id: str, create: Literal[False]
904 ) -> "LegacyParticipantType | None": ...
906 @overload
907 async def get_participant(
908 self, *, occupant_id: str, create: Literal[True]
909 ) -> "LegacyParticipantType": ...
911 @overload
912 async def get_participant(
913 self, nickname: str, *, occupant_id: str
914 ) -> "LegacyParticipantType": ...
916 @overload
917 async def get_participant(
918 self, nickname: str, *, create: Literal[False]
919 ) -> "LegacyParticipantType | None": ...
921 @overload
922 async def get_participant(
923 self, nickname: str, *, create: Literal[True]
924 ) -> "LegacyParticipantType": ...
926 @overload
927 async def get_participant(
928 self,
929 nickname: str,
930 *,
931 create: Literal[True],
932 is_user: bool,
933 fill_first: bool,
934 store: bool,
935 ) -> "LegacyParticipantType": ...
937 @overload
938 async def get_participant(
939 self,
940 nickname: str,
941 *,
942 create: Literal[False],
943 is_user: bool,
944 fill_first: bool,
945 store: bool,
946 ) -> "LegacyParticipantType | None": ...
948 @overload
949 async def get_participant(
950 self,
951 nickname: str,
952 *,
953 create: bool,
954 fill_first: bool,
955 ) -> "LegacyParticipantType | None": ...
957 @overload
958 async def get_participant(
959 self,
960 nickname: str,
961 *,
962 is_user: Literal[True],
963 fill_first: bool,
964 store: bool,
965 occupant_id: str | None = None,
966 ) -> "LegacyParticipantType": ...
968 async def get_participant(
969 self,
970 nickname: str | None = None,
971 *,
972 create: bool = True,
973 is_user: bool = False,
974 fill_first: bool = False,
975 store: bool = True,
976 occupant_id: str | None = None,
977 ) -> "LegacyParticipantType | None":
978 """
979 Get a participant by their nickname.
981 In non-anonymous groups, you probably want to use
982 :meth:`.LegacyMUC.get_participant_by_contact` instead.
984 :param nickname: Nickname of the participant (used as resource part in the MUC)
985 :param create: By default, a participant is created if necessary. Set this to
986 False to return None if participant was not created before.
987 :param is_user: Whether this participant is the slidge user.
988 :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called
989 first (internal use by slidge, plugins should not need that)
990 :param store: persistently store the user in the list of MUC participants
991 :param occupant_id: optionally, specify the unique ID for this participant, cf
992 xep:`0421`
993 :return: A participant of this room.
994 """
995 if not any((nickname, occupant_id)):
996 raise TypeError("You must specify either a nickname or an occupant ID")
997 if fill_first:
998 await self.__fill_participants()
999 if self.stored.id is not None:
1000 with self.xmpp.store.session(expire_on_commit=False) as orm:
1001 if occupant_id is not None:
1002 stored = (
1003 orm.query(Participant)
1004 .filter(
1005 Participant.room == self.stored,
1006 Participant.occupant_id == occupant_id,
1007 )
1008 .one_or_none()
1009 )
1010 elif nickname is not None:
1011 stored = (
1012 orm.query(Participant)
1013 .filter(
1014 Participant.room == self.stored,
1015 (Participant.nickname == nickname)
1016 | (Participant.resource == nickname),
1017 )
1018 .one_or_none()
1019 )
1020 else:
1021 raise RuntimeError("NEVER")
1022 if stored is not None:
1023 if occupant_id and occupant_id != stored.occupant_id:
1024 warnings.warn(
1025 f"Occupant ID mismatch in get_participant(): {occupant_id} vs {stored.occupant_id}",
1026 )
1027 part = self.participant_from_store(stored)
1028 if occupant_id and nickname and nickname != stored.nickname:
1029 stored.nickname = nickname
1030 orm.add(stored)
1031 orm.commit()
1032 return part
1034 if not create:
1035 return None
1037 if occupant_id is None:
1038 occupant_id = "slidge-user" if is_user else str(uuid.uuid4())
1040 if nickname is None:
1041 nickname = occupant_id
1043 with self.xmpp.store.session() as orm:
1044 if not self.xmpp.store.rooms.nick_available(orm, self.stored.id, nickname):
1045 nickname = f"{nickname} ({occupant_id})"
1046 if is_user:
1047 self.user_nick = nickname
1049 p = self.participant_cls(
1050 self,
1051 Participant(
1052 room=self.stored,
1053 nickname=nickname or occupant_id,
1054 is_user=is_user,
1055 occupant_id=occupant_id,
1056 ),
1057 )
1058 if store:
1059 self.__store_participant(p)
1060 self.send_affiliation_change(p)
1061 return p
1063 def send_affiliation_change(
1064 self, part: "LegacyParticipantType", was: MucAffiliation = "none"
1065 ) -> None:
1066 # internal use by slidge
1067 if not self.participants_filled:
1068 return
1069 if self.get_lock("fill participants"):
1070 return
1071 if self.get_lock("fill history"):
1072 return
1073 if part.contact is None:
1074 return
1075 if part.is_system:
1076 return
1077 if was == part.affiliation:
1078 return
1079 system_part = self.get_system_participant()
1080 msg = system_part._make_message(mtype="normal")
1081 data = self.__get_mav_data()
1082 since_data = [
1083 (id_, was if id_ == self.__get_part_mav_id(part) else aff)
1084 for id_, aff in data
1085 ]
1086 if part.affiliation == "none":
1087 since_data.append((self.__get_part_mav_id(part), was))
1088 msg["muc"]["mav"]["since"] = self.__compute_mav_ver(since_data)
1089 msg["muc"]["mav"]["until"] = self.__compute_mav_ver(data)
1090 item = MUCUserItem()
1091 item["affiliation"] = part.affiliation
1092 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare
1093 msg["muc"].append(item)
1094 system_part._send(msg)
1096 def get_system_participant(self) -> "LegacyParticipantType":
1097 """
1098 Get a pseudo-participant, representing the room itself
1100 Can be useful for events that cannot be mapped to a participant,
1101 e.g. anonymous moderation events, or announces from the legacy
1102 service
1103 :return:
1104 """
1105 return self.participant_cls(
1106 self, Participant(occupant_id="room"), is_system=True
1107 )
1109 @overload
1110 async def get_participant_by_contact(
1111 self, c: "LegacyContact"
1112 ) -> "LegacyParticipantType": ...
1114 @overload
1115 async def get_participant_by_contact(
1116 self, c: "LegacyContact", *, occupant_id: str | None = None
1117 ) -> "LegacyParticipantType": ...
1119 @overload
1120 async def get_participant_by_contact(
1121 self,
1122 c: "LegacyContact",
1123 *,
1124 create: Literal[False],
1125 occupant_id: str | None,
1126 ) -> "LegacyParticipantType | None": ...
1128 @overload
1129 async def get_participant_by_contact(
1130 self,
1131 c: "LegacyContact",
1132 *,
1133 create: Literal[True],
1134 occupant_id: str | None,
1135 ) -> "LegacyParticipantType": ...
1137 async def get_participant_by_contact(
1138 self, c: LegacyContact, *, create: bool = True, occupant_id: str | None = None
1139 ) -> "LegacyParticipantType | None":
1140 """
1141 Get a non-anonymous participant.
1143 This is what should be used in non-anonymous groups ideally, to ensure
1144 that the Contact jid is associated to this participant
1146 :param c: The :class:`.LegacyContact` instance corresponding to this contact
1147 :param create: Creates the participant if it does not exist.
1148 :param occupant_id: Optionally, specify a unique occupant ID (:xep:`0421`) for
1149 this participant.
1150 :return:
1151 """
1152 await self.session.contacts.ready
1154 if self.stored.id is not None:
1155 with self.xmpp.store.session() as orm:
1156 self.stored = orm.merge(self.stored)
1157 stored = (
1158 orm.query(Participant)
1159 .filter_by(contact=c.stored, room=self.stored)
1160 .one_or_none()
1161 )
1162 if stored is None:
1163 if occupant_id is not None:
1164 stored = (
1165 orm.query(Participant)
1166 .filter_by(
1167 occupant_id=occupant_id,
1168 room=self.stored,
1169 contact_id=None,
1170 )
1171 .one_or_none()
1172 )
1173 if stored is not None:
1174 self.log.debug(
1175 "Updating the contact of a previously anonymous participant"
1176 )
1177 stored.contact_id = c.stored.id
1178 orm.add(stored)
1179 orm.commit()
1180 return self.participant_from_store(stored=stored, contact=c)
1181 if not create:
1182 return None
1183 else:
1184 if occupant_id and stored.occupant_id != occupant_id:
1185 warnings.warn(
1186 f"Occupant ID mismatch: {occupant_id} vs {stored.occupant_id}",
1187 )
1188 return self.participant_from_store(stored=stored, contact=c)
1190 nickname = c.name or unescape_node(c.jid.node)
1192 if self.stored.id is None:
1193 nick_available = True
1194 else:
1195 with self.xmpp.store.session() as orm:
1196 nick_available = self.xmpp.store.rooms.nick_available(
1197 orm, self.stored.id, nickname
1198 )
1200 if not nick_available:
1201 self.log.debug("Nickname conflict")
1202 nickname = f"{nickname} ({unescape_node(c.jid.node)})"
1203 p = self.participant_cls(
1204 self,
1205 Participant(
1206 nickname=nickname,
1207 room=self.stored,
1208 occupant_id=occupant_id or str(c.jid),
1209 ),
1210 contact=c,
1211 )
1213 self.__store_participant(p)
1214 # FIXME: this is not great but given the current design,
1215 # during participants fill and history backfill we do not
1216 # want to send presence, because we might :update affiliation
1217 # and role afterwards.
1218 # We need a refactor of the MUC class… later™
1219 if (
1220 self.participants_filled
1221 and not self.get_lock("fill participants")
1222 and not self.get_lock("fill history")
1223 ):
1224 self.send_affiliation_change(p)
1225 p.send_last_presence(force=True, no_cache_online=True)
1226 return p
1228 @overload
1229 async def get_participant_by_legacy_id(
1230 self, legacy_id: str
1231 ) -> "LegacyParticipantType": ...
1233 @overload
1234 async def get_participant_by_legacy_id(
1235 self,
1236 legacy_id: str,
1237 *,
1238 occupant_id: str | None,
1239 create: Literal[True],
1240 ) -> "LegacyParticipantType": ...
1242 @overload
1243 async def get_participant_by_legacy_id(
1244 self,
1245 legacy_id: str,
1246 *,
1247 occupant_id: str | None,
1248 ) -> "LegacyParticipantType": ...
1250 @overload
1251 async def get_participant_by_legacy_id(
1252 self,
1253 legacy_id: str,
1254 *,
1255 occupant_id: str | None,
1256 create: Literal[False],
1257 ) -> "LegacyParticipantType | None": ...
1259 async def get_participant_by_legacy_id(
1260 self,
1261 legacy_id: str,
1262 *,
1263 occupant_id: str | None = None,
1264 create: bool = True,
1265 ) -> "LegacyParticipantType":
1266 try:
1267 c = await self.session.contacts.by_legacy_id(legacy_id)
1268 except ContactIsUser:
1269 return await self.get_user_participant(occupant_id=occupant_id)
1270 return await self.get_participant_by_contact( # type:ignore[call-overload,no-any-return]
1271 c, create=create, occupant_id=occupant_id
1272 )
1274 def remove_participant(
1275 self,
1276 p: "LegacyParticipantType",
1277 kick: bool = False,
1278 ban: bool = False,
1279 reason: str | None = None,
1280 ) -> None:
1281 """
1282 Call this when a participant leaves the room
1284 :param p: The participant
1285 :param kick: Whether the participant left because they were kicked
1286 :param ban: Whether the participant left because they were banned
1287 :param reason: Optionally, a reason why the participant was removed.
1288 """
1289 self.log.debug("Removing participant: %s", p)
1290 if kick and ban:
1291 raise TypeError("Either kick or ban")
1292 if kick:
1293 codes = {307}
1294 elif ban:
1295 codes = {301}
1296 else:
1297 codes = None
1298 was = p.stored.affiliation
1299 p.stored.affiliation = "outcast" if ban else "none"
1300 p.stored.role = "none"
1301 presence = p._make_presence(ptype="unavailable", status_codes=codes)
1302 self.send_affiliation_change(p, was)
1303 if reason:
1304 presence["muc"].set_item_attr("reason", reason)
1305 p._send(presence, force=True)
1306 with self.orm() as orm:
1307 self.xmpp.store.participants.delete(orm, p.stored.id)
1308 orm.commit()
1310 def rename_participant(self, old_nickname: str, new_nickname: str) -> None:
1311 with self.xmpp.store.session() as orm:
1312 stored = (
1313 orm.query(Participant)
1314 .filter_by(room=self.stored, nickname=old_nickname)
1315 .one_or_none()
1316 )
1317 if stored is None:
1318 self.log.debug("Tried to rename a participant that we didn't know")
1319 return
1320 p = self.participant_from_store(stored)
1321 if p.nickname == old_nickname:
1322 p.nickname = new_nickname
1324 async def __old_school_history(
1325 self,
1326 full_jid: JID,
1327 maxchars: int | None = None,
1328 maxstanzas: int | None = None,
1329 seconds: int | None = None,
1330 since: datetime | None = None,
1331 ) -> None:
1332 """
1333 Old-style history join (internal slidge use)
1335 :param full_jid:
1336 :param maxchars:
1337 :param maxstanzas:
1338 :param seconds:
1339 :param since:
1340 :return:
1341 """
1342 if since is None:
1343 if seconds is None:
1344 start_date = datetime.now(tz=UTC) - timedelta(days=1)
1345 else:
1346 start_date = datetime.now(tz=UTC) - timedelta(seconds=seconds)
1347 else:
1348 start_date = since or datetime.now(tz=UTC) - timedelta(days=1)
1350 for h_msg in self.archive.get_all(
1351 start_date=start_date, end_date=None, last_page_n=maxstanzas
1352 ):
1353 msg = h_msg.stanza_component_ns
1354 msg["delay"]["stamp"] = h_msg.when
1355 msg["delay"]["from"] = self.jid
1356 msg.set_to(full_jid)
1357 self.xmpp.send(msg, False)
1359 async def send_mam(self, iq: Iq) -> None:
1360 await self.__fill_history()
1362 form_values = iq["mam"]["form"].get_values()
1364 start_date = str_to_datetime_or_none(form_values.get("start"))
1365 end_date = str_to_datetime_or_none(form_values.get("end"))
1367 after_id = form_values.get("after-id")
1368 before_id = form_values.get("before-id")
1370 sender = form_values.get("with")
1372 ids = form_values.get("ids") or ()
1374 if max_str := iq["mam"]["rsm"]["max"]:
1375 try:
1376 max_results = int(max_str)
1377 except ValueError:
1378 max_results = None
1379 else:
1380 max_results = None
1382 after_id_rsm = iq["mam"]["rsm"]["after"]
1383 after_id = after_id_rsm or after_id
1385 before_rsm = iq["mam"]["rsm"]["before"]
1386 if before_rsm is not None and max_results is not None:
1387 last_page_n = max_results
1388 # - before_rsm is True means the empty element <before />, which means
1389 # "last page in chronological order", cf https://xmpp.org/extensions/xep-0059.html#backwards
1390 # - before_rsm == "an ID" means <before>an ID</before>
1391 if before_rsm is not True:
1392 before_id = before_rsm
1393 else:
1394 last_page_n = None
1396 first = None
1397 last = None
1398 count = 0
1400 it = self.archive.get_all(
1401 start_date,
1402 end_date,
1403 before_id,
1404 after_id,
1405 ids,
1406 last_page_n,
1407 sender,
1408 bool(iq["mam"]["flip_page"]),
1409 )
1411 for history_msg in it:
1412 last = xmpp_id = history_msg.id
1413 if first is None:
1414 first = xmpp_id
1416 wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from())
1417 wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"]
1418 wrapper_msg["mam_result"]["id"] = xmpp_id
1419 wrapper_msg["mam_result"].append(history_msg.forwarded())
1421 wrapper_msg.send()
1422 count += 1
1424 if max_results and count == max_results:
1425 break
1427 if max_results:
1428 try:
1429 next(it)
1430 except StopIteration:
1431 complete = True
1432 else:
1433 complete = False
1434 else:
1435 complete = True
1437 reply = iq.reply()
1438 if not self.STABLE_ARCHIVE:
1439 reply["mam_fin"]["stable"] = "false"
1440 if complete:
1441 reply["mam_fin"]["complete"] = "true"
1442 reply["mam_fin"]["rsm"]["first"] = first
1443 reply["mam_fin"]["rsm"]["last"] = last
1444 reply["mam_fin"]["rsm"]["count"] = str(count)
1445 reply.send()
1447 async def send_mam_metadata(self, iq: Iq) -> None:
1448 await self.__fill_history()
1449 await self.archive.send_metadata(iq)
1451 async def kick_resource(self, r: str) -> None:
1452 """
1453 Kick a XMPP client of the user. (slidge internal use)
1455 :param r: The resource to kick
1456 """
1457 pto = JID(self.user_jid)
1458 pto.resource = r
1459 p = self.xmpp.make_presence(
1460 pfrom=(await self.get_user_participant()).jid, pto=pto
1461 )
1462 p["type"] = "unavailable"
1463 p["muc"]["affiliation"] = "none"
1464 p["muc"]["role"] = "none"
1465 p["muc"]["status_codes"] = {110, 333}
1466 p.send()
1468 async def __get_bookmark(self) -> Item | None:
1469 item = Item()
1470 item["id"] = self.jid
1472 iq = Iq(stype="get", sfrom=self.user_jid, sto=self.user_jid)
1473 iq["pubsub"]["items"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS
1474 iq["pubsub"]["items"].append(item)
1476 try:
1477 ans = await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
1478 if len(ans["pubsub"]["items"]) != 1:
1479 return None
1480 # this below creates the item if it wasn't here already
1481 # (slixmpp annoying magic)
1482 item = ans["pubsub"]["items"]["item"]
1483 item["id"] = self.jid
1484 return item # type:ignore[no-any-return]
1485 except IqTimeout:
1486 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: timeout")
1487 return None
1488 except IqError as exc:
1489 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: {exc}")
1490 return None
1491 except PermissionError:
1492 warnings.warn(
1493 f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot fetch the user bookmarks"
1494 )
1495 return None
1497 async def add_to_bookmarks(
1498 self,
1499 auto_join: bool = True,
1500 preserve: bool = True,
1501 pin: bool | None = None,
1502 notify: WhenLiteral | None = None,
1503 ) -> None:
1504 """
1505 Add the MUC to the user's XMPP bookmarks (:xep:`0402`)
1507 This requires that slidge has the IQ privileged set correctly
1508 on the XMPP server
1510 :param auto_join: whether XMPP clients should automatically join
1511 this MUC on startup. In theory, XMPP clients will receive
1512 a "push" notification when this is called, and they will
1513 join if they are online.
1514 :param preserve: preserve auto-join and bookmarks extensions
1515 set by the user outside slidge
1516 :param pin: Pin the group chat bookmark :xep:`0469`. Requires privileged entity.
1517 If set to ``None`` (default), the bookmark pinning status will be untouched.
1518 :param notify: Chat notification setting: :xep:`0492`. Requires privileged entity.
1519 If set to ``None`` (default), the setting will be untouched. Only the "global"
1520 notification setting is supported (ie, per client type is not possible).
1521 """
1522 existing = await self.__get_bookmark() if preserve else None
1523 user_resource = (await self.get_user_participant()).jid.resource
1525 new = Item()
1526 new["id"] = self.jid
1527 new["conference"]["nick"] = user_resource
1529 if existing is None:
1530 change = True
1531 new["conference"]["autojoin"] = auto_join
1532 else:
1533 change = existing["conference"]["nick"] != user_resource
1534 new["conference"]["autojoin"] = existing["conference"]["autojoin"]
1536 existing_extensions = (
1537 existing is not None and "extensions" in existing["conference"]
1538 )
1540 # preserving extensions we don't know about is a MUST
1541 if existing_extensions:
1542 assert existing is not None
1543 for el in existing["conference"]["extensions"].xml:
1544 if el.tag.startswith(f"{{{NOTIFY_NS}}}") and notify is not None:
1545 continue
1546 if el.tag.startswith(f"{{{PINNING_NS}}}") and pin is not None:
1547 continue
1548 new["conference"]["extensions"].append(el)
1550 if pin is not None:
1551 if existing_extensions:
1552 assert existing is not None
1553 existing_pin = (
1554 existing["conference"]["extensions"].get_plugin(
1555 "pinned", check=True
1556 )
1557 is not None
1558 )
1559 if existing_pin != pin:
1560 change = True
1561 new["conference"]["extensions"]["pinned"] = pin
1563 if notify is not None:
1564 new["conference"]["extensions"].enable("notify")
1565 if existing_extensions:
1566 assert existing is not None
1567 existing_notify = existing["conference"]["extensions"].get_plugin(
1568 "notify", check=True
1569 )
1570 if existing_notify is None:
1571 change = True
1572 else:
1573 if existing_notify.get_config() != notify:
1574 change = True
1575 for el in existing_notify:
1576 new["conference"]["extensions"]["notify"].append(el)
1577 new["conference"]["extensions"]["notify"].configure(notify)
1579 if change:
1580 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid)
1581 iq["pubsub"]["publish"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS
1582 iq["pubsub"]["publish"].append(new)
1584 iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS
1586 try:
1587 await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
1588 except PermissionError:
1589 warnings.warn(
1590 f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot add bookmarks for the user"
1591 )
1592 # fallback by forcing invitation
1593 bookmark_add_fail = True
1594 except IqError as e:
1595 warnings.warn(
1596 f"Something went wrong while trying to set the bookmarks: {e}"
1597 )
1598 # fallback by forcing invitation
1599 bookmark_add_fail = True
1600 else:
1601 bookmark_add_fail = False
1602 else:
1603 self.log.debug("Bookmark does not need updating.")
1604 return
1606 if bookmark_add_fail:
1607 self.session.send_gateway_invite(
1608 self,
1609 reason="This group could not be added automatically for you, most"
1610 "likely because this gateway is not configured as a privileged entity. "
1611 "Contact your administrator.",
1612 )
1613 elif existing is None and self.session.user.preferences.get(
1614 "always_invite_when_adding_bookmarks", True
1615 ):
1616 self.session.send_gateway_invite(
1617 self,
1618 reason="The gateway is configured to always send invitations for groups.",
1619 )
1621 async def on_avatar(self, data: bytes | None, mime: str | None) -> str | None:
1622 """
1623 Called when the user tries to set the avatar of the room from an XMPP
1624 client.
1626 If the set avatar operation is completed, should return a legacy image
1627 unique identifier. In this case the MUC avatar will be immediately
1628 updated on the XMPP side.
1630 If data is not None and this method returns None, then we assume that
1631 self.set_avatar() will be called elsewhere, eg triggered by a legacy
1632 room update event.
1634 :param data: image data or None if the user meant to remove the avatar
1635 :param mime: the mime type of the image. Since this is provided by
1636 the XMPP client, there is no guarantee that this is valid or
1637 correct.
1638 :return: A unique avatar identifier, which will trigger
1639 :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if
1640 :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else.
1641 """
1642 raise NotImplementedError
1644 async def on_set_config(
1645 self,
1646 name: str | None,
1647 description: str | None,
1648 ) -> None:
1649 """
1650 Triggered when the user requests changing the room configuration.
1651 Only title and description can be changed at the moment.
1653 The legacy module is responsible for updating :attr:`.title` and/or
1654 :attr:`LegacyMUC.description` of this instance.
1656 If :attr:`.HAS_DESCRIPTION` is set to False, description will always
1657 be ``None``.
1659 :param name: The new name of the room.
1660 :param description: The new description of the room.
1661 """
1662 raise NotImplementedError
1664 async def on_destroy_request(self, reason: str | None) -> None:
1665 """
1666 Triggered when the user requests room destruction.
1668 :param reason: Optionally, a reason for the destruction
1669 """
1670 raise NotImplementedError
1672 async def parse_mentions(
1673 self, text: str | None
1674 ) -> tuple[Mention[LegacyParticipantType], ...]:
1675 if not text:
1676 return ()
1677 with self.xmpp.store.session() as orm:
1678 await self.__fill_participants()
1679 orm.add(self.stored)
1680 participants = {
1681 p.nickname: p for p in self.stored.participants if len(p.nickname) > 1
1682 }
1684 if len(participants) == 0:
1685 return ()
1687 result = []
1688 for match in re.finditer(
1689 "|".join(
1690 sorted(
1691 [re.escape(nick) for nick in participants],
1692 key=lambda nick: len(nick),
1693 reverse=True,
1694 )
1695 ),
1696 text,
1697 ):
1698 span = match.span()
1699 nick = match.group()
1700 if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION:
1701 continue
1702 if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION:
1703 participant = self.participant_from_store(stored=participants[nick])
1704 result.append(
1705 Mention(participant=participant, start=span[0], end=span[1])
1706 )
1707 return tuple(result)
1709 async def on_set_subject(self, subject: str) -> None:
1710 """
1711 Triggered when the user requests changing the room subject.
1713 The legacy module is responsible for updating :attr:`.subject` of this
1714 instance.
1716 :param subject: The new subject for this room.
1717 """
1718 raise NotImplementedError
1720 async def on_set_thread_subject(self, thread: str, subject: str) -> None:
1721 """
1722 Triggered when the user requests changing the subject of a specific thread.
1724 :param thread: Legacy identifier of the thread
1725 :param subject: The new subject for this thread.
1726 """
1727 raise NotImplementedError
1729 async def on_moderate(self, legacy_msg_id: str, reason: str | None) -> None:
1730 """
1731 Triggered when the user attempts to retract a message that was sent in
1732 a MUC using :xep:`0425`.
1734 If retraction is not possible, this should raise the appropriate
1735 XMPPError with a human-readable message.
1737 NB: the legacy module is responsible for calling
1738 :func:`LegacyParticipant.moderate` when this is successful, because
1739 slidge will acknowledge the moderation IQ, but will not send the
1740 moderation message from the MUC automatically.
1742 :param legacy_msg_id: The legacy ID of the message to be retracted
1743 :param reason: Optionally, a reason for the moderation, given by the
1744 user-moderator.
1745 """
1746 raise NotImplementedError
1748 async def on_leave(self) -> None:
1749 """
1750 Triggered when the user leaves a group via the dedicated slidge command
1751 or the :xep:`0077` ``<remove />`` mechanism.
1753 This should be interpreted as definitely leaving the group.
1754 """
1755 raise NotImplementedError
1757 @property
1758 def participants_filled(self) -> bool:
1759 try:
1760 return self.stored.participants_filled
1761 except DetachedInstanceError:
1762 with self.orm(expire_on_commit=False) as orm:
1763 orm.add(self.stored)
1764 with orm.no_autoflush:
1765 orm.refresh(self.stored, ["participants_filled"])
1766 return self.stored.participants_filled
1768 def get_archived_messages(self, msg_id: str) -> Iterator[HistoryMessage]:
1769 """
1770 Query the slidge archive for messages sent in this group
1772 :param msg_id: Message ID of the message in question. Can be either a legacy ID
1773 or an XMPP ID.
1774 :return: Iterator over messages. A single legacy ID can map to several messages,
1775 because of multi-attachment messages.
1776 """
1777 with self.xmpp.store.session() as orm:
1778 for stored in self.xmpp.store.mam.get_messages(
1779 orm, self.stored.id, ids=[str(msg_id)]
1780 ):
1781 yield HistoryMessage(stored.stanza)
1784def set_origin_id(msg: Message, origin_id: str) -> None:
1785 sub = ET.Element("{urn:xmpp:sid:0}origin-id")
1786 sub.attrib["id"] = origin_id
1787 msg.xml.append(sub)
1790def int_or_none(x: str) -> int | None:
1791 try:
1792 return int(x)
1793 except ValueError:
1794 return None
1797def equals_zero(x: int | None) -> bool:
1798 if x is None:
1799 return False
1800 else:
1801 return x == 0
1804def str_to_datetime_or_none(date: str | None) -> datetime | None:
1805 if date is None:
1806 return None
1807 try:
1808 return str_to_datetime(date)
1809 except ValueError:
1810 return None
1813def bookmarks_form() -> Form:
1814 form = Form()
1815 form["type"] = "submit"
1816 form.add_field(
1817 "FORM_TYPE",
1818 value="http://jabber.org/protocol/pubsub#publish-options",
1819 ftype="hidden",
1820 )
1821 form.add_field("pubsub#persist_items", value="1")
1822 form.add_field("pubsub#max_items", value="max")
1823 form.add_field("pubsub#send_last_published_item", value="never")
1824 form.add_field("pubsub#access_model", value="whitelist")
1825 return form
1828LegacyMUC.participant_cls = LegacyParticipant # type:ignore[misc]
1830_BOOKMARKS_OPTIONS = bookmarks_form()
1831_WHITESPACE_OR_PUNCTUATION = string.whitespace + "!\"'(),.:;?@_"
1833log = logging.getLogger(__name__)