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