Coverage for slidge/group/room.py: 89%
943 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-10 04:45 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-10 04:45 +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) -> None:
438 d = d or ""
439 if self.description == d:
440 return
441 self.update_stored_attribute(description=d)
442 self.__send_configuration_change((104,))
444 def on_presence_unavailable(self, p: Presence) -> None:
445 pto = p.get_to()
446 if pto.bare != self.jid.bare:
447 return
449 pfrom = p.get_from()
450 if pfrom.bare != self.user_jid.bare:
451 return
452 if (resource := pfrom.resource) in self.get_user_resources():
453 if pto.resource != self.user_nick:
454 self.log.debug(
455 "Received 'leave group' request but with wrong nickname. %s", p
456 )
457 self.remove_user_resource(resource)
458 else:
459 self.log.debug(
460 "Received 'leave group' request but resource was not listed. %s", p
461 )
463 async def update_info(self) -> None:
464 """
465 Fetch information about this group from the legacy network
467 This is awaited on MUC instantiation, and should be overridden to
468 update the attributes of the group chat, like title, subject, number
469 of participants etc.
471 To take advantage of the slidge avatar cache, you can check the .avatar
472 property to retrieve the "legacy file ID" of the cached avatar. If there
473 is no change, you should not call
474 :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar()` or
475 attempt to modify
476 the :attr:.avatar property.
477 """
478 raise NotImplementedError
480 async def backfill(
481 self,
482 after: HoleBound | None = None,
483 before: HoleBound | None = None,
484 ) -> None:
485 """
486 Override this if the legacy network provide server-side group archives.
488 In it, send history messages using ``self.get_participant(xxx).send_xxxx``,
489 with the ``archive_only=True`` kwarg. This is only called once per slidge
490 run for a given group.
492 :param after: Fetch messages after this one.
493 If ``None``, slidge's local archive was empty before start-up,
494 ie, no history was ever fetched for this room since the user registered.
495 It's up to gateway implementations to decide how far to fetch messages before
496 the user registered.
497 If not ``None``, slidge has some messages in this archive, and
498 the gateway shall try to fetch history up to (and excluding) this message
499 to avoid "holes" in the history of this group.
500 :param before: Fetch messages before this one.
501 If ``None``, the gateway shall fetch all messages up to the most recent one.
502 If not ``None``, slidge has already archived some live messages
503 it received during its lifetime, and there is no need to query the legacy
504 network for any message after (and including) this one.
505 """
506 raise NotImplementedError
508 async def fill_participants(self) -> AsyncIterator[LegacyParticipantType]:
509 """
510 This method should yield the list of all members of this group.
512 Typically, use ``participant = self.get_participant()``, self.get_participant_by_contact(),
513 of self.get_user_participant(), and update their affiliation, hats, etc.
514 before yielding them.
515 """
516 return
517 yield
519 @property
520 def subject(self) -> str:
521 return self.stored.subject or ""
523 @subject.setter
524 def subject(self, s: str) -> None:
525 if s == self.subject:
526 return
528 self.update_stored_attribute(subject=s)
529 self.__get_subject_setter_participant().set_room_subject(
530 s, None, self.subject_date, False
531 )
533 @property
534 def is_anonymous(self) -> bool:
535 return self.type == MucType.CHANNEL
537 @property
538 def subject_setter(self) -> str | None:
539 return self.stored.subject_setter
541 @subject_setter.setter
542 def subject_setter(self, subject_setter: SubjectSetterType) -> None:
543 if isinstance(subject_setter, LegacyContact):
544 subject_setter = subject_setter.name
545 elif isinstance(subject_setter, LegacyParticipant):
546 subject_setter = subject_setter.nickname
548 if subject_setter == self.subject_setter:
549 return
550 assert isinstance(subject_setter, str | None)
551 self.update_stored_attribute(subject_setter=subject_setter)
553 def __get_subject_setter_participant(self) -> AnyParticipant:
554 if self.subject_setter is None:
555 return self.get_system_participant()
556 return self.participant_cls(
557 self,
558 Participant(nickname=self.subject_setter, occupant_id="subject-setter"),
559 )
561 def features(self) -> list[str]:
562 features = [
563 "http://jabber.org/protocol/muc",
564 "http://jabber.org/protocol/muc#stable_id",
565 "http://jabber.org/protocol/muc#self-ping-optimization",
566 "urn:xmpp:mam:2",
567 "urn:xmpp:mam:2#extended",
568 "urn:xmpp:sid:0",
569 "muc_persistent",
570 "vcard-temp",
571 "urn:xmpp:ping",
572 "urn:xmpp:occupant-id:0",
573 "jabber:iq:register",
574 "http://jabber.org/protocol/commands",
575 "urn:xmpp:muc:affiliations:1",
576 self.xmpp.plugin["xep_0425"].stanza.NS,
577 ]
578 if self.type == MucType.GROUP:
579 features.extend(["muc_membersonly", "muc_nonanonymous", "muc_hidden"])
580 elif self.type == MucType.CHANNEL:
581 features.extend(["muc_open", "muc_semianonymous", "muc_public"])
582 elif self.type == MucType.CHANNEL_NON_ANONYMOUS:
583 features.extend(["muc_open", "muc_nonanonymous", "muc_public"])
585 try: # oh boy, this sucks
586 has_space = self.stored.space is not None
587 except DetachedInstanceError:
588 self.refresh()
589 has_space = self.stored.space is not None
591 if has_space:
592 features.append("urn:xmpp:spaces:0")
593 return features
595 async def extended_features(self) -> list[Form]:
596 is_group = self.type == MucType.GROUP
598 form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
600 form.add_field(
601 "FORM_TYPE", "hidden", value="http://jabber.org/protocol/muc#roominfo"
602 )
603 form.add_field("muc#roomconfig_persistentroom", "boolean", value=True)
604 form.add_field("muc#roomconfig_changesubject", "boolean", value=False)
605 form.add_field("muc#maxhistoryfetch", value=str(self.max_history_fetch))
606 form.add_field("muc#roominfo_subjectmod", "boolean", value=False)
608 if self.stored.id is not None and self.participants_filled:
609 with self.xmpp.store.session() as orm:
610 n = orm.scalar(
611 sa.select(sa.func.count(Participant.id)).filter_by(
612 room_id=self.stored.id
613 )
614 )
615 else:
616 n = self.n_participants
617 if n is not None:
618 form.add_field("muc#roominfo_occupants", value=str(n))
620 if d := self.description:
621 form.add_field("muc#roominfo_description", value=d)
623 if s := self.subject:
624 form.add_field("muc#roominfo_subject", value=s)
626 if name := self.name:
627 form.add_field("muc#roomconfig_roomname", value=name)
629 if self._set_avatar_task is not None:
630 await self._set_avatar_task
631 avatar = self.get_avatar()
632 if avatar and (h := avatar.id):
633 form.add_field(
634 "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1", value=h
635 )
636 form.add_field("muc#roominfo_avatarhash", "text-multi", value=[h])
637 if avatar.url:
638 form.add_field("{http://slidge.im}/avatar#url", value=avatar.url)
640 form.add_field("muc#roomconfig_membersonly", "boolean", value=is_group)
641 form.add_field(
642 "muc#roomconfig_whois",
643 "list-single",
644 value="moderators" if self.is_anonymous else "anyone",
645 )
646 form.add_field("muc#roomconfig_publicroom", "boolean", value=not is_group)
647 form.add_field("muc#roomconfig_allowpm", "boolean", value=False)
649 r = [form]
651 if reaction_form := await self.restricted_emoji_extended_feature():
652 r.append(reaction_form)
654 if self.stored.space is not None:
655 node = await self.session.bookmarks.space_legacy_id_to_node(
656 self.stored.space.legacy_id
657 )
658 iri = f"xmpp:{self.xmpp.boundjid.bare}?node={node}"
659 form.add_field("muc#roominfo_pubsub", value=iri)
660 space_form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
661 space_form.add_field("FORM_TYPE", "hidden", value="urn:xmpp:spaces:0")
662 space_form.add_field("parent", label="Space parent", value=iri)
664 return r
666 def shutdown(self) -> None:
667 _, user_jid = escape_nickname(self.jid, self.user_nick)
668 for user_full_jid in self.user_full_jids():
669 presence = self.xmpp.make_presence(
670 pfrom=user_jid, pto=user_full_jid, ptype="unavailable"
671 )
672 presence["muc"]["affiliation"] = "none"
673 presence["muc"]["role"] = "none"
674 presence["muc"]["status_codes"] = {110, 332}
675 presence.send()
677 def user_full_jids(self) -> Iterable[JID]:
678 for r in self.get_user_resources():
679 j = JID(self.user_jid)
680 j.resource = r
681 yield j
683 @property
684 def user_muc_jid(self) -> JID:
685 _, user_muc_jid = escape_nickname(self.jid, self.user_nick)
686 return user_muc_jid
688 async def echo(self, msg: Message, legacy_msg_id: str | None = None) -> str:
689 msg.set_from(self.user_muc_jid)
690 if legacy_msg_id:
691 msg["stanza_id"]["id"] = legacy_msg_id
692 else:
693 msg["stanza_id"]["id"] = str(uuid.uuid4())
694 msg["stanza_id"]["by"] = self.jid
696 user_part = await self.get_user_participant()
697 msg["occupant-id"]["id"] = user_part.stored.occupant_id
699 self.archive.add(msg, user_part)
701 for user_full_jid in self.user_full_jids():
702 self.log.debug("Echoing to %s", user_full_jid)
703 msg = copy(msg)
704 msg.set_to(user_full_jid)
706 msg.send()
708 return msg["stanza_id"]["id"] # type:ignore[no-any-return]
710 def _post_avatar_update(self, cached_avatar: "CachedAvatar | None") -> None:
711 self.__send_configuration_change((104,))
712 self._send_room_presence()
714 def _send_room_presence(self, user_full_jid: JID | None = None) -> None:
715 tos = self.user_full_jids() if user_full_jid is None else [user_full_jid]
716 for to in tos:
717 p = self.xmpp.make_presence(pfrom=self.jid, pto=to)
718 if (avatar := self.get_avatar()) and (h := avatar.id):
719 p["vcard_temp_update"]["photo"] = h
720 if avatar.http_metadata is not None:
721 metadata = self.xmpp.plugin["xep_0084"].stanza.MetaData()
722 metadata.append(avatar.http_metadata)
723 p.append(metadata)
724 else:
725 p["vcard_temp_update"]["photo"] = ""
726 p.send()
728 @timeit
729 async def join(self, join_presence: Presence) -> None:
730 user_full_jid = join_presence.get_from()
731 requested_nickname = join_presence.get_to().resource
732 client_resource = user_full_jid.resource
734 if client_resource in self.get_user_resources():
735 self.log.debug("Received join from a resource that is already joined.")
737 if not requested_nickname or not client_resource:
738 raise XMPPError("jid-malformed", by=self.jid)
740 self.add_user_resource(client_resource)
742 self.log.debug(
743 "Resource %s of %s wants to join room %s with nickname %s",
744 client_resource,
745 self.user_jid,
746 self.legacy_id,
747 requested_nickname,
748 )
750 user_nick = self.user_nick
751 user_participant = None
752 await self.__fill_participants()
753 if "mav" in join_presence["muc_join"]:
754 mav_until = await self.__get_mav()
755 self.log.debug("client uses MUC affiliation versioning")
756 if join_presence["muc_join"]["mav"]["since"] != mav_until:
757 self.log.debug(
758 "client mav: %s vs our mav: %s",
759 join_presence["muc_join"]["mav"]["since"],
760 mav_until,
761 )
762 await self.__send_mav(user_full_jid, mav_until)
763 else:
764 mav_until = None
765 async for participant in self.get_participants():
766 if participant.is_user:
767 user_participant = participant
768 continue
769 participant.send_initial_presence(full_jid=user_full_jid)
771 if user_participant is None:
772 user_participant = await self.get_user_participant()
773 with self.xmpp.store.session() as orm:
774 orm.add(self.stored)
775 with orm.no_autoflush:
776 orm.refresh(self.stored, ["participants"])
777 if not user_participant.is_user:
778 self.log.warning("is_user flag not set on user_participant")
779 user_participant.is_user = True
780 user_participant.send_initial_presence(
781 user_full_jid,
782 presence_id=join_presence["id"],
783 nick_change=user_nick != requested_nickname,
784 mav_until=mav_until,
785 )
787 history_params = join_presence["muc_join"]["history"]
788 maxchars = int_or_none(history_params["maxchars"])
789 maxstanzas = int_or_none(history_params["maxstanzas"])
790 seconds = int_or_none(history_params["seconds"])
791 try:
792 since = self.xmpp.plugin["xep_0082"].parse(history_params["since"])
793 except ValueError:
794 since = None
795 if seconds is not None:
796 since = datetime.now(tz=UTC) - timedelta(seconds=seconds)
797 if equals_zero(maxchars) or equals_zero(maxstanzas):
798 log.debug("Joining client does not want any old-school MUC history-on-join")
799 else:
800 self.log.debug("Old school history fill")
801 await self.__fill_history()
802 await self.__old_school_history(
803 user_full_jid,
804 maxchars=maxchars,
805 maxstanzas=maxstanzas,
806 since=since,
807 )
808 if self.HAS_SUBJECT:
809 subject = self.subject or ""
810 else:
811 subject = self.description or self.name or ""
812 self.__get_subject_setter_participant().set_room_subject(
813 subject,
814 user_full_jid,
815 self.subject_date,
816 )
817 if t := self._set_avatar_task:
818 await t
819 self._send_room_presence(user_full_jid)
821 async def __get_mav(self) -> str:
822 data = self.__get_mav_data()
823 return self.__compute_mav_ver(data)
825 def __get_mav_data(self) -> list[tuple[str, MucAffiliation]]:
826 data: list[tuple[str, MucAffiliation]] = []
827 with self.orm(expire_on_commit=False) as orm:
828 self.stored = orm.merge(self.stored)
829 for part in self.stored.participants:
830 if not (part.is_user or part.contact):
831 continue
832 if part.affiliation == "none":
833 continue
834 data.append((self.__get_part_mav_id(part), part.affiliation))
835 return data
837 def __get_part_mav_id(self, part: "LegacyParticipantType | Participant") -> str:
838 return str(self.user_jid if part.is_user else part.contact.legacy_id) # type:ignore[union-attr] # ty:ignore[unresolved-attribute]
840 def __compute_mav_ver(self, data: list[tuple[Any, MucAffiliation]]) -> str:
841 self.log.debug("MAV data: %s", data)
842 affs = []
843 for id_, aff in data:
844 if aff == "none":
845 continue
846 affs.append(f"{id_}\0{aff}".encode())
847 affs.sort()
848 return hashlib.sha256(b"\0".join(affs)).hexdigest()
850 async def __send_mav(self, full_jid: JID, until: str) -> None:
851 msg = self.xmpp.make_message(mto=full_jid, mfrom=self.jid)
852 msg["muc"]["mav"]["until"] = until
853 with self.orm(expire_on_commit=False) as orm:
854 self.stored = orm.merge(self.stored)
855 for part in self.stored.participants:
856 if not (part.is_user or part.contact):
857 continue
858 item = MUCUserItem()
859 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare # type:ignore[union-attr]
860 item["affiliation"] = part.affiliation
861 msg["muc"].append(item)
862 msg.send()
864 async def get_user_participant(
865 self,
866 *,
867 fill_first: bool = False,
868 store: bool = True,
869 occupant_id: str | None = None,
870 ) -> "LegacyParticipantType":
871 """
872 Get the participant representing the gateway user
874 :param kwargs: additional parameters for the :class:`.Participant`
875 construction (optional)
876 :return:
877 """
878 p = await self.get_participant(
879 self.user_nick,
880 is_user=True,
881 fill_first=fill_first,
882 store=store,
883 occupant_id=occupant_id,
884 )
885 self.__store_participant(p)
886 return p
888 def __store_participant(self, p: "LegacyParticipantType") -> None:
889 if self.get_lock("fill participants"):
890 return
891 try:
892 p.commit()
893 except IntegrityError as e:
894 log.debug("Could not store participant: %r", e)
895 self.stored = p.stored.room
897 @overload
898 async def get_participant(self, nickname: str) -> "LegacyParticipantType": ...
900 @overload
901 async def get_participant(
902 self, nickname: str, *, store: bool
903 ) -> "LegacyParticipantType": ...
905 @overload
906 async def get_participant(self, *, occupant_id: str) -> "LegacyParticipantType": ...
908 @overload
909 async def get_participant(
910 self, *, occupant_id: str, create: Literal[False]
911 ) -> "LegacyParticipantType | None": ...
913 @overload
914 async def get_participant(
915 self, *, occupant_id: str, create: Literal[True]
916 ) -> "LegacyParticipantType": ...
918 @overload
919 async def get_participant(
920 self, nickname: str, *, occupant_id: str
921 ) -> "LegacyParticipantType": ...
923 @overload
924 async def get_participant(
925 self, nickname: str, *, create: Literal[False]
926 ) -> "LegacyParticipantType | None": ...
928 @overload
929 async def get_participant(
930 self, nickname: str, *, create: Literal[True]
931 ) -> "LegacyParticipantType": ...
933 @overload
934 async def get_participant(
935 self,
936 nickname: str,
937 *,
938 create: Literal[True],
939 is_user: bool,
940 fill_first: bool,
941 store: bool,
942 ) -> "LegacyParticipantType": ...
944 @overload
945 async def get_participant(
946 self,
947 nickname: str,
948 *,
949 create: Literal[False],
950 is_user: bool,
951 fill_first: bool,
952 store: bool,
953 ) -> "LegacyParticipantType | None": ...
955 @overload
956 async def get_participant(
957 self,
958 nickname: str,
959 *,
960 create: bool,
961 fill_first: bool,
962 ) -> "LegacyParticipantType | None": ...
964 @overload
965 async def get_participant(
966 self,
967 nickname: str,
968 *,
969 is_user: Literal[True],
970 fill_first: bool,
971 store: bool,
972 occupant_id: str | None = None,
973 ) -> "LegacyParticipantType": ...
975 async def get_participant(
976 self,
977 nickname: str | None = None,
978 *,
979 create: bool = True,
980 is_user: bool = False,
981 fill_first: bool = False,
982 store: bool = True,
983 occupant_id: str | None = None,
984 ) -> "LegacyParticipantType | None":
985 """
986 Get a participant by their nickname.
988 In non-anonymous groups, you probably want to use
989 :meth:`.LegacyMUC.get_participant_by_contact` instead.
991 :param nickname: Nickname of the participant (used as resource part in the MUC)
992 :param create: By default, a participant is created if necessary. Set this to
993 False to return None if participant was not created before.
994 :param is_user: Whether this participant is the slidge user.
995 :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called
996 first (internal use by slidge, plugins should not need that)
997 :param store: persistently store the user in the list of MUC participants
998 :param occupant_id: optionally, specify the unique ID for this participant, cf
999 xep:`0421`
1000 :return: A participant of this room.
1001 """
1002 if not any((nickname, occupant_id)):
1003 raise TypeError("You must specify either a nickname or an occupant ID")
1004 if fill_first:
1005 await self.__fill_participants()
1006 if self.stored.id is not None:
1007 with self.xmpp.store.session(expire_on_commit=False) as orm:
1008 if occupant_id is not None:
1009 stored = (
1010 orm.query(Participant)
1011 .filter(
1012 Participant.room == self.stored,
1013 Participant.occupant_id == occupant_id,
1014 )
1015 .one_or_none()
1016 )
1017 elif nickname is not None:
1018 stored = (
1019 orm.query(Participant)
1020 .filter(
1021 Participant.room == self.stored,
1022 (Participant.nickname == nickname)
1023 | (Participant.resource == nickname),
1024 )
1025 .one_or_none()
1026 )
1027 else:
1028 raise RuntimeError("NEVER")
1029 if stored is not None:
1030 if occupant_id and occupant_id != stored.occupant_id:
1031 warnings.warn(
1032 f"Occupant ID mismatch in get_participant(): {occupant_id} vs {stored.occupant_id}",
1033 )
1034 part = self.participant_from_store(stored)
1035 if occupant_id and nickname and nickname != stored.nickname:
1036 stored.nickname = nickname
1037 orm.add(stored)
1038 orm.commit()
1039 return part
1041 if not create:
1042 return None
1044 if occupant_id is None:
1045 occupant_id = "slidge-user" if is_user else str(uuid.uuid4())
1047 if nickname is None:
1048 nickname = occupant_id
1050 with self.xmpp.store.session() as orm:
1051 if not self.xmpp.store.rooms.nick_available(orm, self.stored.id, nickname):
1052 nickname = f"{nickname} ({occupant_id})"
1053 if is_user:
1054 self.user_nick = nickname
1056 p = self.participant_cls(
1057 self,
1058 Participant(
1059 room=self.stored,
1060 nickname=nickname or occupant_id,
1061 is_user=is_user,
1062 occupant_id=occupant_id,
1063 ),
1064 )
1065 if store:
1066 self.__store_participant(p)
1067 self.send_affiliation_change(p)
1068 return p
1070 def send_affiliation_change(
1071 self, part: "LegacyParticipantType", was: MucAffiliation = "none"
1072 ) -> None:
1073 # internal use by slidge
1074 if not self.participants_filled:
1075 return
1076 if self.get_lock("fill participants"):
1077 return
1078 if self.get_lock("fill history"):
1079 return
1080 if part.contact is None:
1081 return
1082 if part.is_system:
1083 return
1084 if was == part.affiliation:
1085 return
1086 system_part = self.get_system_participant()
1087 msg = system_part._make_message(mtype="normal")
1088 data = self.__get_mav_data()
1089 since_data = [
1090 (id_, was if id_ == self.__get_part_mav_id(part) else aff)
1091 for id_, aff in data
1092 ]
1093 if part.affiliation == "none":
1094 since_data.append((self.__get_part_mav_id(part), was))
1095 msg["muc"]["mav"]["since"] = self.__compute_mav_ver(since_data)
1096 msg["muc"]["mav"]["until"] = self.__compute_mav_ver(data)
1097 item = MUCUserItem()
1098 item["affiliation"] = part.affiliation
1099 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare
1100 msg["muc"].append(item)
1101 system_part._send(msg)
1103 def get_system_participant(self) -> "LegacyParticipantType":
1104 """
1105 Get a pseudo-participant, representing the room itself
1107 Can be useful for events that cannot be mapped to a participant,
1108 e.g. anonymous moderation events, or announces from the legacy
1109 service
1110 :return:
1111 """
1112 return self.participant_cls(
1113 self, Participant(occupant_id="room"), is_system=True
1114 )
1116 @overload
1117 async def get_participant_by_contact(
1118 self, c: "LegacyContact"
1119 ) -> "LegacyParticipantType": ...
1121 @overload
1122 async def get_participant_by_contact(
1123 self, c: "LegacyContact", *, occupant_id: str | None = None
1124 ) -> "LegacyParticipantType": ...
1126 @overload
1127 async def get_participant_by_contact(
1128 self,
1129 c: "LegacyContact",
1130 *,
1131 create: Literal[False],
1132 occupant_id: str | None,
1133 ) -> "LegacyParticipantType | None": ...
1135 @overload
1136 async def get_participant_by_contact(
1137 self,
1138 c: "LegacyContact",
1139 *,
1140 create: Literal[True],
1141 occupant_id: str | None,
1142 ) -> "LegacyParticipantType": ...
1144 async def get_participant_by_contact(
1145 self, c: LegacyContact, *, create: bool = True, occupant_id: str | None = None
1146 ) -> "LegacyParticipantType | None":
1147 """
1148 Get a non-anonymous participant.
1150 This is what should be used in non-anonymous groups ideally, to ensure
1151 that the Contact jid is associated to this participant
1153 :param c: The :class:`.LegacyContact` instance corresponding to this contact
1154 :param create: Creates the participant if it does not exist.
1155 :param occupant_id: Optionally, specify a unique occupant ID (:xep:`0421`) for
1156 this participant.
1157 :return:
1158 """
1159 await self.session.contacts.ready
1161 if self.stored.id is not None:
1162 with self.xmpp.store.session() as orm:
1163 self.stored = orm.merge(self.stored)
1164 stored = (
1165 orm.query(Participant)
1166 .filter_by(contact=c.stored, room=self.stored)
1167 .one_or_none()
1168 )
1169 if stored is None:
1170 if occupant_id is not None:
1171 stored = (
1172 orm.query(Participant)
1173 .filter_by(
1174 occupant_id=occupant_id,
1175 room=self.stored,
1176 contact_id=None,
1177 )
1178 .one_or_none()
1179 )
1180 if stored is not None:
1181 self.log.debug(
1182 "Updating the contact of a previously anonymous participant"
1183 )
1184 stored.contact_id = c.stored.id
1185 orm.add(stored)
1186 orm.commit()
1187 return self.participant_from_store(stored=stored, contact=c)
1188 if not create:
1189 return None
1190 else:
1191 if occupant_id and stored.occupant_id != occupant_id:
1192 warnings.warn(
1193 f"Occupant ID mismatch: {occupant_id} vs {stored.occupant_id}",
1194 )
1195 return self.participant_from_store(stored=stored, contact=c)
1197 nickname = c.name or unescape_node(c.jid.node)
1199 if self.stored.id is None:
1200 nick_available = True
1201 else:
1202 with self.xmpp.store.session() as orm:
1203 nick_available = self.xmpp.store.rooms.nick_available(
1204 orm, self.stored.id, nickname
1205 )
1207 if not nick_available:
1208 self.log.debug("Nickname conflict")
1209 nickname = f"{nickname} ({unescape_node(c.jid.node)})"
1210 p = self.participant_cls(
1211 self,
1212 Participant(
1213 nickname=nickname,
1214 room=self.stored,
1215 occupant_id=occupant_id or str(c.jid),
1216 ),
1217 contact=c,
1218 )
1220 self.__store_participant(p)
1221 # FIXME: this is not great but given the current design,
1222 # during participants fill and history backfill we do not
1223 # want to send presence, because we might :update affiliation
1224 # and role afterwards.
1225 # We need a refactor of the MUC class… later™
1226 if (
1227 self.participants_filled
1228 and not self.get_lock("fill participants")
1229 and not self.get_lock("fill history")
1230 ):
1231 self.send_affiliation_change(p)
1232 p.send_last_presence(force=True, no_cache_online=True)
1233 return p
1235 @overload
1236 async def get_participant_by_legacy_id(
1237 self, legacy_id: str
1238 ) -> "LegacyParticipantType": ...
1240 @overload
1241 async def get_participant_by_legacy_id(
1242 self,
1243 legacy_id: str,
1244 *,
1245 occupant_id: str | None,
1246 create: Literal[True],
1247 ) -> "LegacyParticipantType": ...
1249 @overload
1250 async def get_participant_by_legacy_id(
1251 self,
1252 legacy_id: str,
1253 *,
1254 occupant_id: str | None,
1255 ) -> "LegacyParticipantType": ...
1257 @overload
1258 async def get_participant_by_legacy_id(
1259 self,
1260 legacy_id: str,
1261 *,
1262 occupant_id: str | None,
1263 create: Literal[False],
1264 ) -> "LegacyParticipantType | None": ...
1266 async def get_participant_by_legacy_id(
1267 self,
1268 legacy_id: str,
1269 *,
1270 occupant_id: str | None = None,
1271 create: bool = True,
1272 ) -> "LegacyParticipantType":
1273 try:
1274 c = await self.session.contacts.by_legacy_id(legacy_id)
1275 except ContactIsUser:
1276 return await self.get_user_participant(occupant_id=occupant_id)
1277 return await self.get_participant_by_contact( # type:ignore[call-overload,no-any-return]
1278 c, create=create, occupant_id=occupant_id
1279 )
1281 def remove_participant(
1282 self,
1283 p: "LegacyParticipantType",
1284 kick: bool = False,
1285 ban: bool = False,
1286 reason: str | None = None,
1287 ) -> None:
1288 """
1289 Call this when a participant leaves the room
1291 :param p: The participant
1292 :param kick: Whether the participant left because they were kicked
1293 :param ban: Whether the participant left because they were banned
1294 :param reason: Optionally, a reason why the participant was removed.
1295 """
1296 self.log.debug("Removing participant: %s", p)
1297 if kick and ban:
1298 raise TypeError("Either kick or ban")
1299 if kick:
1300 codes = {307}
1301 elif ban:
1302 codes = {301}
1303 else:
1304 codes = None
1305 was = p.stored.affiliation
1306 p.stored.affiliation = "outcast" if ban else "none"
1307 p.stored.role = "none"
1308 presence = p._make_presence(ptype="unavailable", status_codes=codes)
1309 self.send_affiliation_change(p, was)
1310 if reason:
1311 presence["muc"].set_item_attr("reason", reason)
1312 p._send(presence, force=True)
1313 with self.orm() as orm:
1314 self.xmpp.store.participants.delete(orm, p.stored.id)
1315 orm.commit()
1317 def rename_participant(self, old_nickname: str, new_nickname: str) -> None:
1318 with self.xmpp.store.session() as orm:
1319 stored = (
1320 orm.query(Participant)
1321 .filter_by(room=self.stored, nickname=old_nickname)
1322 .one_or_none()
1323 )
1324 if stored is None:
1325 self.log.debug("Tried to rename a participant that we didn't know")
1326 return
1327 p = self.participant_from_store(stored)
1328 if p.nickname == old_nickname:
1329 p.nickname = new_nickname
1331 async def __old_school_history(
1332 self,
1333 full_jid: JID,
1334 maxchars: int | None = None,
1335 maxstanzas: int | None = None,
1336 seconds: int | None = None,
1337 since: datetime | None = None,
1338 ) -> None:
1339 """
1340 Old-style history join (internal slidge use)
1342 :param full_jid:
1343 :param maxchars:
1344 :param maxstanzas:
1345 :param seconds:
1346 :param since:
1347 :return:
1348 """
1349 if since is None:
1350 if seconds is None:
1351 start_date = datetime.now(tz=UTC) - timedelta(days=1)
1352 else:
1353 start_date = datetime.now(tz=UTC) - timedelta(seconds=seconds)
1354 else:
1355 start_date = since or datetime.now(tz=UTC) - timedelta(days=1)
1357 for h_msg in self.archive.get_all(
1358 start_date=start_date, end_date=None, last_page_n=maxstanzas
1359 ):
1360 msg = h_msg.stanza_component_ns
1361 msg["delay"]["stamp"] = h_msg.when
1362 msg["delay"]["from"] = self.jid
1363 msg.set_to(full_jid)
1364 self.xmpp.send(msg, False)
1366 async def send_mam(self, iq: Iq) -> None:
1367 await self.__fill_history()
1369 form_values = iq["mam"]["form"].get_values()
1371 start_date = str_to_datetime_or_none(form_values.get("start"))
1372 end_date = str_to_datetime_or_none(form_values.get("end"))
1374 after_id = form_values.get("after-id")
1375 before_id = form_values.get("before-id")
1377 sender = form_values.get("with")
1379 ids = form_values.get("ids") or ()
1381 if max_str := iq["mam"]["rsm"]["max"]:
1382 try:
1383 max_results = int(max_str)
1384 except ValueError:
1385 max_results = None
1386 else:
1387 max_results = None
1389 after_id_rsm = iq["mam"]["rsm"]["after"]
1390 after_id = after_id_rsm or after_id
1392 before_rsm = iq["mam"]["rsm"]["before"]
1393 if before_rsm is not None and max_results is not None:
1394 last_page_n = max_results
1395 # - before_rsm is True means the empty element <before />, which means
1396 # "last page in chronological order", cf https://xmpp.org/extensions/xep-0059.html#backwards
1397 # - before_rsm == "an ID" means <before>an ID</before>
1398 if before_rsm is not True:
1399 before_id = before_rsm
1400 else:
1401 last_page_n = None
1403 first = None
1404 last = None
1405 count = 0
1407 it = self.archive.get_all(
1408 start_date,
1409 end_date,
1410 before_id,
1411 after_id,
1412 ids,
1413 last_page_n,
1414 sender,
1415 bool(iq["mam"]["flip_page"]),
1416 )
1418 for history_msg in it:
1419 last = xmpp_id = history_msg.id
1420 if first is None:
1421 first = xmpp_id
1423 wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from())
1424 wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"]
1425 wrapper_msg["mam_result"]["id"] = xmpp_id
1426 wrapper_msg["mam_result"].append(history_msg.forwarded())
1428 wrapper_msg.send()
1429 count += 1
1431 if max_results and count == max_results:
1432 break
1434 if max_results:
1435 try:
1436 next(it)
1437 except StopIteration:
1438 complete = True
1439 else:
1440 complete = False
1441 else:
1442 complete = True
1444 reply = iq.reply()
1445 if not self.STABLE_ARCHIVE:
1446 reply["mam_fin"]["stable"] = "false"
1447 if complete:
1448 reply["mam_fin"]["complete"] = "true"
1449 reply["mam_fin"]["rsm"]["first"] = first
1450 reply["mam_fin"]["rsm"]["last"] = last
1451 reply["mam_fin"]["rsm"]["count"] = str(count)
1452 reply.send()
1454 async def send_mam_metadata(self, iq: Iq) -> None:
1455 await self.__fill_history()
1456 await self.archive.send_metadata(iq)
1458 async def kick_resource(self, r: str) -> None:
1459 """
1460 Kick a XMPP client of the user. (slidge internal use)
1462 :param r: The resource to kick
1463 """
1464 pto = JID(self.user_jid)
1465 pto.resource = r
1466 p = self.xmpp.make_presence(
1467 pfrom=(await self.get_user_participant()).jid, pto=pto
1468 )
1469 p["type"] = "unavailable"
1470 p["muc"]["affiliation"] = "none"
1471 p["muc"]["role"] = "none"
1472 p["muc"]["status_codes"] = {110, 333}
1473 p.send()
1475 async def __get_bookmark(self) -> Item | None:
1476 item = Item()
1477 item["id"] = self.jid
1479 iq = Iq(stype="get", sfrom=self.user_jid, sto=self.user_jid)
1480 iq["pubsub"]["items"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS
1481 iq["pubsub"]["items"].append(item)
1483 try:
1484 ans = await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
1485 if len(ans["pubsub"]["items"]) != 1:
1486 return None
1487 # this below creates the item if it wasn't here already
1488 # (slixmpp annoying magic)
1489 item = ans["pubsub"]["items"]["item"]
1490 item["id"] = self.jid
1491 return item # type:ignore[no-any-return]
1492 except IqTimeout:
1493 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: timeout")
1494 return None
1495 except IqError as exc:
1496 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: {exc}")
1497 return None
1498 except PermissionError:
1499 warnings.warn(
1500 f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot fetch the user bookmarks"
1501 )
1502 return None
1504 async def add_to_bookmarks(
1505 self,
1506 auto_join: bool = True,
1507 preserve: bool = True,
1508 pin: bool | None = None,
1509 notify: WhenLiteral | None = None,
1510 ) -> None:
1511 """
1512 Add the MUC to the user's XMPP bookmarks (:xep:`0402`)
1514 This requires that slidge has the IQ privileged set correctly
1515 on the XMPP server
1517 :param auto_join: whether XMPP clients should automatically join
1518 this MUC on startup. In theory, XMPP clients will receive
1519 a "push" notification when this is called, and they will
1520 join if they are online.
1521 :param preserve: preserve auto-join and bookmarks extensions
1522 set by the user outside slidge
1523 :param pin: Pin the group chat bookmark :xep:`0469`. Requires privileged entity.
1524 If set to ``None`` (default), the bookmark pinning status will be untouched.
1525 :param notify: Chat notification setting: :xep:`0492`. Requires privileged entity.
1526 If set to ``None`` (default), the setting will be untouched. Only the "global"
1527 notification setting is supported (ie, per client type is not possible).
1528 """
1529 existing = await self.__get_bookmark() if preserve else None
1530 user_resource = (await self.get_user_participant()).jid.resource
1532 new = Item()
1533 new["id"] = self.jid
1534 new["conference"]["nick"] = user_resource
1536 invite = self.session.user.preferences.get(
1537 "always_invite_when_adding_bookmarks", True
1538 )
1539 if existing is None:
1540 change = True
1541 new["conference"]["autojoin"] = auto_join
1542 else:
1543 change = existing["conference"]["nick"] != user_resource
1544 if not existing["conference"]["autojoin"]:
1545 invite = False
1546 new["conference"]["autojoin"] = existing["conference"]["autojoin"]
1548 existing_extensions = (
1549 existing is not None and "extensions" in existing["conference"]
1550 )
1552 # preserving extensions we don't know about is a MUST
1553 if existing_extensions:
1554 assert existing is not None
1555 for el in existing["conference"]["extensions"].xml:
1556 if el.tag.startswith(f"{{{NOTIFY_NS}}}") and notify is not None:
1557 continue
1558 if el.tag.startswith(f"{{{PINNING_NS}}}") and pin is not None:
1559 continue
1560 new["conference"]["extensions"].append(el)
1562 if pin is not None:
1563 if existing_extensions:
1564 assert existing is not None
1565 existing_pin = (
1566 existing["conference"]["extensions"].get_plugin(
1567 "pinned", check=True
1568 )
1569 is not None
1570 )
1571 if existing_pin != pin:
1572 change = True
1573 new["conference"]["extensions"]["pinned"] = pin
1575 if notify is not None:
1576 new["conference"]["extensions"].enable("notify")
1577 if existing_extensions:
1578 assert existing is not None
1579 existing_notify = existing["conference"]["extensions"].get_plugin(
1580 "notify", check=True
1581 )
1582 if existing_notify is None:
1583 change = True
1584 else:
1585 if existing_notify.get_config() != notify:
1586 change = True
1587 for el in existing_notify:
1588 new["conference"]["extensions"]["notify"].append(el)
1589 new["conference"]["extensions"]["notify"].configure(notify)
1591 if change:
1592 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid)
1593 iq["pubsub"]["publish"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS
1594 iq["pubsub"]["publish"].append(new)
1596 iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS
1598 update_success = False
1599 try:
1600 await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
1601 except PermissionError:
1602 warnings.warn(
1603 f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot add bookmarks for the user"
1604 )
1605 except IqError as e:
1606 warnings.warn(
1607 f"Something went wrong while trying to set the bookmarks: {e}"
1608 )
1609 else:
1610 update_success = True
1611 if existing is None and not update_success and not invite:
1612 self.session.send_gateway_invite(
1613 self,
1614 reason="This group could not be added automatically for you, most "
1615 "likely because this gateway is not configured as a privileged entity. "
1616 "Contact your administrator.",
1617 )
1618 return
1619 else:
1620 self.log.debug("Bookmark does not need updating.")
1622 if invite:
1623 self.session.send_gateway_invite(
1624 self.jid,
1625 reason="The gateway is configured to send invitations for groups.",
1626 )
1628 async def remove_from_bookmarks(self) -> None:
1629 """Remove the MUC from the user's XMPP bookmarks (:xep:`0402`).
1631 Does not actually leave the room on the legacy network, nor apply any other
1632 side-effect.
1634 Requires that slidge has the IQ privileged set correctly on the XMPP server.
1635 """
1636 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid)
1637 iq["pubsub"]["retract"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS
1638 iq["pubsub"]["retract"]["notify"] = "true"
1639 iq["pubsub"]["retract"]["item"]["id"] = str(self.jid)
1640 try:
1641 await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
1642 except PermissionError:
1643 warnings.warn(
1644 f"IQ privileges (XEP0356) not granted for {self.user_jid}, "
1645 "we cannot remove bookmarks for the user"
1646 )
1647 except IqError as e:
1648 warnings.warn(
1649 f"Something went wrong while trying to remove a bookmark: {e}"
1650 )
1652 async def on_avatar(self, data: bytes | None, mime: str | None) -> str | None:
1653 """
1654 Called when the user tries to set the avatar of the room from an XMPP
1655 client.
1657 If the set avatar operation is completed, should return a legacy image
1658 unique identifier. In this case the MUC avatar will be immediately
1659 updated on the XMPP side.
1661 If data is not None and this method returns None, then we assume that
1662 self.set_avatar() will be called elsewhere, eg triggered by a legacy
1663 room update event.
1665 :param data: image data or None if the user meant to remove the avatar
1666 :param mime: the mime type of the image. Since this is provided by
1667 the XMPP client, there is no guarantee that this is valid or
1668 correct.
1669 :return: A unique avatar identifier, which will trigger
1670 :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if
1671 :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else.
1672 """
1673 raise NotImplementedError
1675 async def on_set_config(
1676 self,
1677 name: str | None,
1678 description: str | None,
1679 ) -> None:
1680 """
1681 Triggered when the user requests changing the room configuration.
1682 Only title and description can be changed at the moment.
1684 The legacy module is responsible for updating :attr:`.title` and/or
1685 :attr:`LegacyMUC.description` of this instance.
1687 If :attr:`.HAS_DESCRIPTION` is set to False, description will always
1688 be ``None``.
1690 :param name: The new name of the room.
1691 :param description: The new description of the room.
1692 """
1693 raise NotImplementedError
1695 async def on_destroy_request(self, reason: str | None) -> None:
1696 """
1697 Triggered when the user requests room destruction.
1699 :param reason: Optionally, a reason for the destruction
1700 """
1701 raise NotImplementedError
1703 async def parse_mentions(
1704 self, text: str | None
1705 ) -> tuple[Mention[LegacyParticipantType], ...]:
1706 if not text:
1707 return ()
1708 with self.xmpp.store.session() as orm:
1709 await self.__fill_participants()
1710 orm.add(self.stored)
1711 participants = {
1712 p.nickname: p for p in self.stored.participants if len(p.nickname) > 1
1713 }
1715 if len(participants) == 0:
1716 return ()
1718 result = []
1719 for match in re.finditer(
1720 "|".join(
1721 sorted(
1722 [re.escape(nick) for nick in participants],
1723 key=lambda nick: len(nick),
1724 reverse=True,
1725 )
1726 ),
1727 text,
1728 ):
1729 span = match.span()
1730 nick = match.group()
1731 if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION:
1732 continue
1733 if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION:
1734 participant = self.participant_from_store(stored=participants[nick])
1735 result.append(
1736 Mention(participant=participant, start=span[0], end=span[1])
1737 )
1738 return tuple(result)
1740 async def on_set_subject(self, subject: str) -> None:
1741 """
1742 Triggered when the user requests changing the room subject.
1744 The legacy module is responsible for updating :attr:`.subject` of this
1745 instance.
1747 :param subject: The new subject for this room.
1748 """
1749 raise NotImplementedError
1751 async def on_set_thread_subject(self, thread: str, subject: str) -> None:
1752 """
1753 Triggered when the user requests changing the subject of a specific thread.
1755 :param thread: Legacy identifier of the thread
1756 :param subject: The new subject for this thread.
1757 """
1758 raise NotImplementedError
1760 async def on_moderate(self, legacy_msg_id: str, reason: str | None) -> None:
1761 """
1762 Triggered when the user attempts to retract a message that was sent in
1763 a MUC using :xep:`0425`.
1765 If retraction is not possible, this should raise the appropriate
1766 XMPPError with a human-readable message.
1768 NB: the legacy module is responsible for calling
1769 :func:`LegacyParticipant.moderate` when this is successful, because
1770 slidge will acknowledge the moderation IQ, but will not send the
1771 moderation message from the MUC automatically.
1773 :param legacy_msg_id: The legacy ID of the message to be retracted
1774 :param reason: Optionally, a reason for the moderation, given by the
1775 user-moderator.
1776 """
1777 raise NotImplementedError
1779 async def on_leave(self) -> None:
1780 """
1781 Triggered when the user leaves a group via the dedicated slidge command
1782 or the :xep:`0077` ``<remove />`` mechanism.
1784 This should be interpreted as definitely leaving the group.
1785 """
1786 raise NotImplementedError
1788 @property
1789 def participants_filled(self) -> bool:
1790 # We don't store anything about participants before fill_participants()
1791 # has been called…
1792 if type(self).fill_participants is LegacyMUC.fill_participants:
1793 # …except if concrete MUC implementations do not override the
1794 # default no-op implementation.
1795 return True
1796 try:
1797 return self.stored.participants_filled
1798 except DetachedInstanceError:
1799 with self.orm(expire_on_commit=False) as orm:
1800 orm.add(self.stored)
1801 with orm.no_autoflush:
1802 orm.refresh(self.stored, ["participants_filled"])
1803 return self.stored.participants_filled
1805 def get_archived_messages(self, msg_id: str) -> Iterator[HistoryMessage]:
1806 """
1807 Query the slidge archive for messages sent in this group
1809 :param msg_id: Message ID of the message in question. Can be either a legacy ID
1810 or an XMPP ID.
1811 :return: Iterator over messages. A single legacy ID can map to several messages,
1812 because of multi-attachment messages.
1813 """
1814 with self.xmpp.store.session() as orm:
1815 for stored in self.xmpp.store.mam.get_messages(
1816 orm, self.stored.id, ids=[str(msg_id)]
1817 ):
1818 yield HistoryMessage(stored.stanza)
1821def set_origin_id(msg: Message, origin_id: str) -> None:
1822 sub = ET.Element("{urn:xmpp:sid:0}origin-id")
1823 sub.attrib["id"] = origin_id
1824 msg.xml.append(sub)
1827def int_or_none(x: str) -> int | None:
1828 try:
1829 return int(x)
1830 except ValueError:
1831 return None
1834def equals_zero(x: int | None) -> bool:
1835 if x is None:
1836 return False
1837 else:
1838 return x == 0
1841def str_to_datetime_or_none(date: str | None) -> datetime | None:
1842 if date is None:
1843 return None
1844 try:
1845 return str_to_datetime(date)
1846 except ValueError:
1847 return None
1850def bookmarks_form() -> Form:
1851 form = Form()
1852 form["type"] = "submit"
1853 form.add_field(
1854 "FORM_TYPE",
1855 value="http://jabber.org/protocol/pubsub#publish-options",
1856 ftype="hidden",
1857 )
1858 form.add_field("pubsub#persist_items", value="1")
1859 form.add_field("pubsub#max_items", value="max")
1860 form.add_field("pubsub#send_last_published_item", value="never")
1861 form.add_field("pubsub#access_model", value="whitelist")
1862 return form
1865LegacyMUC.participant_cls = LegacyParticipant # type:ignore[misc]
1867_BOOKMARKS_OPTIONS = bookmarks_form()
1868_WHITESPACE_OR_PUNCTUATION = string.whitespace + "!\"'(),.:;?@_"
1870log = logging.getLogger(__name__)