Coverage for slidge/group/room.py: 89%
971 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-22 03:37 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-22 03:37 +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 import object_session
29from sqlalchemy.orm.exc import DetachedInstanceError
31from ..contact.contact import LegacyContact
32from ..contact.roster import ContactIsUser
33from ..core.mixins.avatar import AvatarMixin
34from ..core.mixins.base import SessionBound
35from ..core.mixins.disco import ChatterDiscoMixin
36from ..core.mixins.recipient import RecipientMixin
37from ..db.models import ArchivedMessage, Participant, Room, Space
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,
194 stored: Participant,
195 contact: LegacyContact | None = None,
196 for_join: bool = False,
197 ) -> LegacyParticipantType:
198 if contact is None and stored.contact_id is not None and not for_join:
199 if "contact" in sa.inspect(stored).unloaded:
200 with self.xmpp.store.session() as orm, orm.no_autoflush:
201 stored = orm.merge(stored)
202 orm.refresh(stored, attribute_names=["contact"])
203 assert stored.contact is not None
204 contact = self.session.contacts.from_store(stored.contact)
205 return self.participant_cls(
206 self, stored=stored, contact=contact, for_join=for_join
207 )
209 @property
210 def jid(self) -> JID:
211 return self.stored.jid
213 @jid.setter
214 def jid(self, x: JID) -> None:
215 # FIXME: without this, mypy yields
216 # "Cannot override writeable attribute with read-only property"
217 # But it does not happen for LegacyContact. WTF?
218 raise RuntimeError
220 @property
221 def legacy_id(self) -> str:
222 return self.stored.legacy_id
224 def __stored_space(self) -> Space | None:
225 if self.stored.space_id is None:
226 return None
227 if (
228 "space" in sa.inspect(self.stored).unloaded
229 and object_session(self.stored) is None
230 ):
231 with self.xmpp.store.session() as orm, orm.no_autoflush:
232 orm.add(self.stored)
233 return self.stored.space
234 return self.stored.space
236 @property
237 def space_legacy_id(self) -> str | None:
238 if self.stored.space_id is None:
239 return None
240 stored = self.__stored_space()
241 if stored is None:
242 return None
243 return stored.legacy_id
245 @space_legacy_id.setter
246 def space_legacy_id(self, legacy_id: str) -> None:
247 if self.space_legacy_id == legacy_id:
248 return
249 with self.orm(expire_on_commit=False) as orm:
250 space = self.xmpp.store.spaces.add_or_get(orm, self.user_pk, str(legacy_id))
251 if self._updating_info:
252 # Merge *before* assigning the space. ``Room.space`` has a
253 # backref to ``Space.rooms``: assigning ``space`` to the
254 # (detached) ``self.stored`` first would append it to
255 # ``space.rooms``, and then ``merge()`` would load a second
256 # instance of the same row in this session and append that one
257 # too. ``space.rooms`` would then hold two distinct objects for
258 # the same primary key, and any later ``orm.add(self.stored)``
259 # in another session would cascade through it and raise
260 # "Can't attach instance <Room>; another instance with key
261 # [...] is already present in this session."
262 with orm.no_autoflush:
263 stored = orm.merge(self.stored)
264 stored.space = space
265 self.stored = stored
266 return
267 self.stored.space = space
268 orm.add(self.stored)
269 orm.commit()
271 def orm(
272 self,
273 **kwargs: Any, # noqa:ANN401
274 ) -> OrmSession:
275 return self.xmpp.store.session(**kwargs)
277 @property
278 def type(self) -> MucType:
279 return self.stored.muc_type
281 @type.setter
282 def type(self, type_: MucType) -> None:
283 if self.type == type_:
284 return
285 self.update_stored_attribute(muc_type=type_)
287 @property
288 def n_participants(self) -> int | None:
289 self.stored.ensure_loaded("disco", self.xmpp.store.session)
290 return self.stored.n_participants
292 @n_participants.setter
293 def n_participants(self, n_participants: int | None) -> None:
294 if self.n_participants == n_participants:
295 return
296 self.update_stored_attribute(n_participants=n_participants)
298 def _set_logger(self) -> None:
299 self.log = logging.getLogger(f"{self.user_jid}:muc:{self}")
301 def __repr__(self) -> str:
302 return f"<MUC #{self.stored.id} '{self.name}' ({self.stored.legacy_id} - {self.jid.user})'>"
304 @property
305 def subject_date(self) -> datetime | None:
306 self.stored.ensure_loaded("subject", self.xmpp.store.session)
307 if self.stored.subject_date is None:
308 return None
309 return self.stored.subject_date.replace(tzinfo=UTC)
311 @subject_date.setter
312 def subject_date(self, when: datetime | None) -> None:
313 if self.subject_date == when:
314 return
315 self.update_stored_attribute(subject_date=when)
317 def __send_configuration_change(self, codes: tuple[int, ...]) -> None:
318 part = self.get_system_participant()
319 part.send_configuration_change(codes)
321 @property
322 def user_nick(self) -> str:
323 return (
324 self.stored.user_nick
325 or self.session.bookmarks.user_nick
326 or self.user_jid.node
327 )
329 @user_nick.setter
330 def user_nick(self, nick: str) -> None:
331 if nick == self.user_nick:
332 return
333 self.update_stored_attribute(user_nick=nick)
335 def add_user_resource(self, resource: str) -> None:
336 stored_set = self.get_user_resources()
337 if resource in stored_set:
338 return
339 stored_set.add(resource)
340 self.update_stored_attribute(
341 user_resources=(json.dumps(list(stored_set)) if stored_set else None)
342 )
344 def get_user_resources(self) -> set[str]:
345 stored_str = self.stored.user_resources
346 if stored_str is None:
347 return set()
348 return set(json.loads(stored_str))
350 def remove_user_resource(self, resource: str) -> None:
351 stored_set = self.get_user_resources()
352 if resource not in stored_set:
353 return
354 stored_set.remove(resource)
355 self.update_stored_attribute(
356 user_resources=(json.dumps(list(stored_set)) if stored_set else None)
357 )
359 @asynccontextmanager
360 async def lock(self, id_: str) -> AsyncIterator[None]:
361 async with self.session.lock((self.legacy_id, id_)):
362 yield
364 def get_lock(self, id_: str) -> Lock | None:
365 return self.session.get_lock((self.legacy_id, id_))
367 async def __fill_participants(self) -> None:
368 if self.participants_filled:
369 return
371 async with self.lock("fill participants"):
372 with self.xmpp.store.session(expire_on_commit=False) as orm:
373 orm.add(self.stored)
374 with orm.no_autoflush:
375 orm.refresh(self.stored, ["participants_filled"])
376 if self.participants_filled:
377 return
378 self.log.debug("Filling participants")
379 parts: list[Participant] = []
380 resources = set[str]()
381 # During fill_participants(), self.get_participant*() methods may
382 # return a participant with a conflicting nick/resource.
383 user_found = False
384 async for participant in self.fill_participants():
385 if participant.is_user:
386 user_found = True
387 if participant.stored.resource in resources:
388 self.log.debug(
389 "Participant '%s' was yielded more than once by fill_participants(), ignoring",
390 participant.stored.resource,
391 )
392 continue
393 parts.append(participant.stored)
394 resources.add(participant.stored.resource)
396 if not user_found:
397 participant = await self.get_user_participant()
398 if participant.stored.resource in resources:
399 for p in parts:
400 if p.resource == participant.jid.resource:
401 p.is_user = True
402 else:
403 parts.append(participant.stored)
404 resources.add(participant.stored.resource)
406 with self.xmpp.store.session(expire_on_commit=False) as orm:
407 orm.add(self.stored)
408 # because self.fill_participants() is async, self.stored may be stale at
409 # this point, and the only thing we want to update is the participant list
410 # and the participant_filled attribute.
411 with orm.no_autoflush:
412 orm.refresh(self.stored, ["participants"])
413 for part in parts:
414 orm.merge(part)
415 self.stored.participants_filled = True
416 orm.commit()
418 self.log.debug("Filling participants: done")
420 async def get_participants(
421 self, affiliation: MucAffiliation | None = None
422 ) -> AsyncIterator[LegacyParticipantType]:
423 await self.__fill_participants()
424 with self.xmpp.store.session(expire_on_commit=False, autoflush=False) as orm:
425 self.stored = orm.merge(self.stored)
426 db_participants = self.stored.participants
427 for db_participant in db_participants:
428 if affiliation is not None and db_participant.affiliation != affiliation:
429 continue
430 yield self.participant_from_store(db_participant)
432 async def __fill_history(self) -> None:
433 async with self.lock("fill history"):
434 with self.xmpp.store.session(expire_on_commit=False) as orm:
435 orm.add(self.stored)
436 with orm.no_autoflush:
437 orm.refresh(self.stored, ["history_filled"])
438 if self.stored.history_filled:
439 self.log.debug("History has already been fetched.")
440 return
441 log.debug("Fetching history for %s", self)
442 try:
443 await self.backfill(*self.archive.get_hole_bounds())
444 except NotImplementedError:
445 return
446 except Exception as e:
447 self.log.exception("Could not backfill", exc_info=e)
449 self.stored.history_filled = True
450 self.commit()
452 def _get_disco_name(self) -> str | None:
453 return self.name
455 @property
456 def name(self) -> str | None:
457 return self.stored.name
459 @name.setter
460 def name(self, n: str | None) -> None:
461 if self.name == n:
462 return
463 self.update_stored_attribute(name=n)
464 self._set_logger()
465 self.__send_configuration_change((104,))
467 @property
468 def description(self) -> str:
469 self.stored.ensure_loaded("disco", self.xmpp.store.session)
470 return self.stored.description or ""
472 @description.setter
473 def description(self, d: str | None) -> None:
474 d = d or ""
475 if self.description == d:
476 return
477 self.update_stored_attribute(description=d)
478 self.__send_configuration_change((104,))
480 def on_presence_unavailable(self, p: Presence) -> None:
481 pto = p.get_to()
482 if pto.bare != self.jid.bare:
483 return
485 pfrom = p.get_from()
486 if pfrom.bare != self.user_jid.bare:
487 return
488 if (resource := pfrom.resource) in self.get_user_resources():
489 if pto.resource != self.user_nick:
490 self.log.debug(
491 "Received 'leave group' request but with wrong nickname. %s", p
492 )
493 self.remove_user_resource(resource)
494 else:
495 self.log.debug(
496 "Received 'leave group' request but resource was not listed. %s", p
497 )
499 async def update_info(self) -> None:
500 """
501 Fetch information about this group from the legacy network
503 This is awaited on MUC instantiation, and should be overridden to
504 update the attributes of the group chat, like title, subject, number
505 of participants etc.
507 To take advantage of the slidge avatar cache, you can check the .avatar
508 property to retrieve the "legacy file ID" of the cached avatar. If there
509 is no change, you should not call
510 :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar()` or
511 attempt to modify
512 the :attr:.avatar property.
514 :raises XMPPError: MUST be raised when the legacy group does not exist.
515 """
516 raise NotImplementedError
518 async def backfill(
519 self,
520 after: HoleBound | None = None,
521 before: HoleBound | None = None,
522 ) -> None:
523 """
524 Override this if the legacy network provide server-side group archives.
526 In it, send history messages using ``self.get_participant(xxx).send_xxxx``,
527 with the ``archive_only=True`` kwarg. This is only called once per slidge
528 run for a given group.
530 :param after: Fetch messages after this one.
531 If ``None``, slidge's local archive was empty before start-up,
532 ie, no history was ever fetched for this room since the user registered.
533 It's up to gateway implementations to decide how far to fetch messages before
534 the user registered.
535 If not ``None``, slidge has some messages in this archive, and
536 the gateway shall try to fetch history up to (and excluding) this message
537 to avoid "holes" in the history of this group.
538 :param before: Fetch messages before this one.
539 If ``None``, the gateway shall fetch all messages up to the most recent one.
540 If not ``None``, slidge has already archived some live messages
541 it received during its lifetime, and there is no need to query the legacy
542 network for any message after (and including) this one.
543 """
544 raise NotImplementedError
546 async def fill_participants(self) -> AsyncIterator[LegacyParticipantType]:
547 """
548 This method should yield the list of all members of this group.
550 Typically, use ``participant = self.get_participant()``, self.get_participant_by_contact(),
551 of self.get_user_participant(), and update their affiliation, hats, etc.
552 before yielding them.
553 """
554 return
555 yield
557 @property
558 def subject(self) -> str:
559 self.stored.ensure_loaded("subject", self.xmpp.store.session)
560 return self.stored.subject or ""
562 @subject.setter
563 def subject(self, s: str) -> None:
564 if s == self.subject:
565 return
567 self.update_stored_attribute(subject=s)
568 self.__get_subject_setter_participant().set_room_subject(
569 s, None, self.subject_date, False
570 )
572 @property
573 def is_anonymous(self) -> bool:
574 return self.type == MucType.CHANNEL
576 @property
577 def subject_setter(self) -> str | None:
578 self.stored.ensure_loaded("subject", self.xmpp.store.session)
579 return self.stored.subject_setter
581 @subject_setter.setter
582 def subject_setter(self, subject_setter: SubjectSetterType) -> None:
583 if isinstance(subject_setter, LegacyContact):
584 subject_setter = subject_setter.name
585 elif isinstance(subject_setter, LegacyParticipant):
586 subject_setter = subject_setter.nickname
588 if subject_setter == self.subject_setter:
589 return
590 assert isinstance(subject_setter, str | None)
591 self.update_stored_attribute(subject_setter=subject_setter)
593 def __get_subject_setter_participant(self) -> AnyParticipant:
594 if self.subject_setter is None:
595 return self.get_system_participant()
596 return self.participant_cls(
597 self,
598 Participant(nickname=self.subject_setter, occupant_id="subject-setter"),
599 )
601 def features(self) -> list[str]:
602 features = [
603 "http://jabber.org/protocol/muc",
604 "http://jabber.org/protocol/muc#stable_id",
605 "http://jabber.org/protocol/muc#self-ping-optimization",
606 "urn:xmpp:mam:2",
607 "urn:xmpp:mam:2#extended",
608 "urn:xmpp:sid:0",
609 "muc_persistent",
610 "vcard-temp",
611 "urn:xmpp:ping",
612 "urn:xmpp:occupant-id:0",
613 "jabber:iq:register",
614 "http://jabber.org/protocol/commands",
615 "urn:xmpp:muc:affiliations:1",
616 self.xmpp.plugin["xep_0425"].stanza.NS,
617 ]
618 if self.type == MucType.GROUP:
619 features.extend(["muc_membersonly", "muc_nonanonymous", "muc_hidden"])
620 elif self.type == MucType.CHANNEL:
621 features.extend(["muc_open", "muc_semianonymous", "muc_public"])
622 elif self.type == MucType.CHANNEL_NON_ANONYMOUS:
623 features.extend(["muc_open", "muc_nonanonymous", "muc_public"])
625 if self.stored.space_id is not None:
626 features.append("urn:xmpp:spaces:0")
627 return features
629 async def extended_features(self) -> list[Form]:
630 is_group = self.type == MucType.GROUP
632 form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
634 form.add_field(
635 "FORM_TYPE", "hidden", value="http://jabber.org/protocol/muc#roominfo"
636 )
637 form.add_field("muc#roomconfig_persistentroom", "boolean", value=True)
638 form.add_field("muc#roomconfig_changesubject", "boolean", value=False)
639 form.add_field("muc#maxhistoryfetch", value=str(self.max_history_fetch))
640 form.add_field("muc#roominfo_subjectmod", "boolean", value=False)
642 self.stored.ensure_loaded("disco", self.xmpp.store.session)
644 if self.stored.id is not None and self.participants_filled:
645 with self.xmpp.store.session() as orm:
646 n = orm.scalar(
647 sa.select(sa.func.count(Participant.id)).filter_by(
648 room_id=self.stored.id
649 )
650 )
651 else:
652 n = self.stored.n_participants
654 if n is not None:
655 form.add_field("muc#roominfo_occupants", value=str(n))
657 if d := self.stored.description:
658 form.add_field("muc#roominfo_description", value=d)
660 if s := self.subject:
661 form.add_field("muc#roominfo_subject", value=s)
663 if name := self.name:
664 form.add_field("muc#roomconfig_roomname", value=name)
666 if self._set_avatar_task is not None:
667 await self._set_avatar_task
668 avatar = self.get_avatar()
669 if avatar and (h := avatar.id):
670 form.add_field(
671 "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1", value=h
672 )
673 form.add_field("muc#roominfo_avatarhash", "text-multi", value=[h])
674 if avatar.url:
675 form.add_field("{http://slidge.im}/avatar#url", value=avatar.url)
677 form.add_field("muc#roomconfig_membersonly", "boolean", value=is_group)
678 form.add_field(
679 "muc#roomconfig_whois",
680 "list-single",
681 value="moderators" if self.is_anonymous else "anyone",
682 )
683 form.add_field("muc#roomconfig_publicroom", "boolean", value=not is_group)
684 form.add_field("muc#roomconfig_allowpm", "boolean", value=False)
686 r = [form]
688 if reaction_form := await self.restricted_emoji_extended_feature():
689 r.append(reaction_form)
691 if self.stored.space_id is not None:
692 stored = self.__stored_space()
693 assert stored is not None
694 node = self.session.bookmarks.space_legacy_id_to_node(stored.legacy_id)
695 iri = f"xmpp:{self.xmpp.boundjid.bare}?node={node}"
696 form.add_field("muc#roominfo_pubsub", value=iri)
697 space_form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
698 space_form.add_field("FORM_TYPE", "hidden", value="urn:xmpp:spaces:0")
699 space_form.add_field("parent", label="Space parent", value=iri)
701 return r
703 def shutdown(self) -> None:
704 _, user_jid = escape_nickname(self.jid, self.user_nick)
705 for user_full_jid in self.user_full_jids():
706 presence = self.xmpp.make_presence(
707 pfrom=user_jid, pto=user_full_jid, ptype="unavailable"
708 )
709 presence["muc"]["affiliation"] = "none"
710 presence["muc"]["role"] = "none"
711 presence["muc"]["status_codes"] = {110, 332}
712 presence.send()
714 def user_full_jids(self) -> Iterable[JID]:
715 for r in self.get_user_resources():
716 j = JID(self.user_jid)
717 j.resource = r
718 yield j
720 @property
721 def user_muc_jid(self) -> JID:
722 _, user_muc_jid = escape_nickname(self.jid, self.user_nick)
723 return user_muc_jid
725 async def echo(self, msg: Message, legacy_msg_id: str | None = None) -> str:
726 msg.set_from(self.user_muc_jid)
727 if legacy_msg_id:
728 msg["stanza_id"]["id"] = legacy_msg_id
729 else:
730 msg["stanza_id"]["id"] = str(uuid.uuid4())
731 msg["stanza_id"]["by"] = self.jid
733 user_part = await self.get_user_participant()
734 msg["occupant-id"]["id"] = user_part.stored.occupant_id
736 self.archive.add(msg, user_part, legacy_msg_id=legacy_msg_id)
738 for user_full_jid in self.user_full_jids():
739 self.log.debug("Echoing to %s", user_full_jid)
740 msg = copy(msg)
741 msg.set_to(user_full_jid)
743 msg.send()
745 return msg["stanza_id"]["id"] # type:ignore[no-any-return]
747 def _post_avatar_update(self, cached_avatar: "CachedAvatar | None") -> None:
748 self.__send_configuration_change((104,))
749 self._send_room_presence()
751 def _send_room_presence(self, user_full_jid: JID | None = None) -> None:
752 tos = self.user_full_jids() if user_full_jid is None else [user_full_jid]
753 for to in tos:
754 p = self.xmpp.make_presence(pfrom=self.jid, pto=to)
755 if (avatar := self.get_avatar()) and (h := avatar.id):
756 p["vcard_temp_update"]["photo"] = h
757 if avatar.http_metadata is not None:
758 metadata = self.xmpp.plugin["xep_0084"].stanza.MetaData()
759 metadata.append(avatar.http_metadata)
760 p.append(metadata)
761 else:
762 p["vcard_temp_update"]["photo"] = ""
763 p.send()
765 @timeit
766 async def join(self, join_presence: Presence) -> None:
767 user_full_jid = join_presence.get_from()
768 requested_nickname = join_presence.get_to().resource
769 client_resource = user_full_jid.resource
771 if client_resource in self.get_user_resources():
772 self.log.debug("Received join from a resource that is already joined.")
774 if not requested_nickname or not client_resource:
775 raise XMPPError("jid-malformed", by=self.jid)
777 self.add_user_resource(client_resource)
779 self.log.debug(
780 "Resource %s of %s wants to join room %s with nickname %s",
781 client_resource,
782 self.user_jid,
783 self.legacy_id,
784 requested_nickname,
785 )
787 await self.__fill_participants()
788 if "mav" in join_presence["muc_join"]:
789 mav_until = await self.__get_mav()
790 self.log.debug("client uses MUC affiliation versioning")
791 if join_presence["muc_join"]["mav"]["since"] != mav_until:
792 self.log.debug(
793 "client mav: %s vs our mav: %s",
794 join_presence["muc_join"]["mav"]["since"],
795 mav_until,
796 )
797 await self.__send_mav(user_full_jid, mav_until)
798 else:
799 mav_until = None
801 self.log.debug("Sending presences for join of %s", client_resource)
803 self.stored.load_participants(self.xmpp.store.session, with_presence=True)
804 user_participant = None
806 part_pks = []
807 for db_participant in self.stored.participants:
808 participant = self.participant_from_store(db_participant, for_join=True)
809 if participant.is_user:
810 user_participant = participant
811 continue
812 participant.send_initial_presence(full_jid=user_full_jid)
813 part_pks.append(db_participant.id)
815 with self.xmpp.store.session(expire_on_commit=False) as orm:
816 orm.execute(
817 sa.update(Participant)
818 .where(Participant.id.in_(part_pks))
819 .values(presence_sent=True)
820 )
821 orm.commit()
823 if user_participant is None:
824 user_participant = await self.get_user_participant()
825 with self.xmpp.store.session() as orm:
826 orm.add(self.stored)
827 with orm.no_autoflush:
828 orm.refresh(self.stored, ["participants"])
829 if not user_participant.is_user:
830 self.log.warning("is_user flag not set on user_participant")
831 user_participant.is_user = True
832 user_participant.send_initial_presence(
833 user_full_jid,
834 presence_id=join_presence["id"],
835 nick_change=self.user_nick != requested_nickname,
836 mav_until=mav_until,
837 )
839 self.log.debug("Sending presences for join of %s: DONE", client_resource)
841 history_params = join_presence["muc_join"]["history"]
842 maxchars = int_or_none(history_params["maxchars"])
843 maxstanzas = int_or_none(history_params["maxstanzas"])
844 seconds = int_or_none(history_params["seconds"])
845 try:
846 since = self.xmpp.plugin["xep_0082"].parse(history_params["since"])
847 except ValueError:
848 since = None
849 if seconds is not None:
850 since = datetime.now(tz=UTC) - timedelta(seconds=seconds)
851 if equals_zero(maxchars) or equals_zero(maxstanzas):
852 log.debug("Joining client does not want any old-school MUC history-on-join")
853 else:
854 self.log.debug("Old school history fill")
855 await self.__fill_history()
856 await self.__old_school_history(
857 user_full_jid,
858 maxchars=maxchars,
859 maxstanzas=maxstanzas,
860 since=since,
861 )
862 if self.HAS_SUBJECT:
863 subject = self.subject or ""
864 else:
865 subject = self.description or self.name or ""
866 self.__get_subject_setter_participant().set_room_subject(
867 subject,
868 user_full_jid,
869 self.subject_date,
870 )
871 if t := self._set_avatar_task:
872 await t
873 self._send_room_presence(user_full_jid)
875 async def __get_mav(self) -> str:
876 data = self.__get_mav_data()
877 return self.__compute_mav_ver(data)
879 def __get_mav_data(self) -> list[tuple[str, MucAffiliation]]:
880 data: list[tuple[str, MucAffiliation]] = []
881 self.stored.load_participants(self.orm, with_presence=False)
882 for part in self.stored.participants:
883 if not (part.is_user or part.contact):
884 continue
885 if part.affiliation == "none":
886 continue
887 data.append((self.__get_part_mav_id(part), part.affiliation))
888 return data
890 def __get_part_mav_id(self, part: "LegacyParticipantType | Participant") -> str:
891 return str(self.user_jid if part.is_user else part.contact.legacy_id) # type:ignore[union-attr] # ty:ignore[unresolved-attribute]
893 def __compute_mav_ver(self, data: list[tuple[Any, MucAffiliation]]) -> str:
894 self.log.debug("MAV data: %s", data)
895 affs = []
896 for id_, aff in data:
897 if aff == "none":
898 continue
899 affs.append(f"{id_}\0{aff}".encode())
900 affs.sort()
901 return hashlib.sha256(b"\0".join(affs)).hexdigest()
903 async def __send_mav(self, full_jid: JID, until: str) -> None:
904 msg = self.xmpp.make_message(mto=full_jid, mfrom=self.jid)
905 msg["muc"]["mav"]["until"] = until
906 with self.orm(expire_on_commit=False) as orm:
907 self.stored = orm.merge(self.stored)
908 for part in self.stored.participants:
909 if not (part.is_user or part.contact):
910 continue
911 item = MUCUserItem()
912 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare # type:ignore[union-attr]
913 item["affiliation"] = part.affiliation
914 msg["muc"].append(item)
915 msg.send()
917 async def get_user_participant(
918 self,
919 *,
920 fill_first: bool = False,
921 store: bool = True,
922 occupant_id: str | None = None,
923 ) -> "LegacyParticipantType":
924 """
925 Get the participant representing the gateway user
927 :param kwargs: additional parameters for the :class:`.Participant`
928 construction (optional)
929 :return:
930 """
931 p = await self.get_participant(
932 self.user_nick,
933 is_user=True,
934 fill_first=fill_first,
935 store=store,
936 occupant_id=occupant_id,
937 )
938 self.__store_participant(p)
939 return p
941 def __store_participant(self, p: "LegacyParticipantType") -> None:
942 if self.get_lock("fill participants"):
943 return
944 try:
945 p.commit()
946 except IntegrityError as e:
947 log.debug("Could not store participant: %r", e)
949 @overload
950 async def get_participant(self, nickname: str) -> "LegacyParticipantType": ...
952 @overload
953 async def get_participant(
954 self, nickname: str, *, store: bool
955 ) -> "LegacyParticipantType": ...
957 @overload
958 async def get_participant(self, *, occupant_id: str) -> "LegacyParticipantType": ...
960 @overload
961 async def get_participant(
962 self, *, occupant_id: str, create: Literal[False]
963 ) -> "LegacyParticipantType | None": ...
965 @overload
966 async def get_participant(
967 self, nickname: str, *, occupant_id: str, create: bool
968 ) -> "LegacyParticipantType | None": ...
970 @overload
971 async def get_participant(
972 self, *, occupant_id: str, create: Literal[True]
973 ) -> "LegacyParticipantType": ...
975 @overload
976 async def get_participant(
977 self, nickname: str, *, occupant_id: str
978 ) -> "LegacyParticipantType": ...
980 @overload
981 async def get_participant(
982 self, nickname: str, *, create: Literal[False]
983 ) -> "LegacyParticipantType | None": ...
985 @overload
986 async def get_participant(
987 self, nickname: str, *, create: Literal[True]
988 ) -> "LegacyParticipantType": ...
990 @overload
991 async def get_participant(
992 self,
993 nickname: str,
994 *,
995 create: Literal[True],
996 is_user: bool,
997 fill_first: bool,
998 store: bool,
999 ) -> "LegacyParticipantType": ...
1001 @overload
1002 async def get_participant(
1003 self,
1004 nickname: str,
1005 *,
1006 create: Literal[False],
1007 is_user: bool,
1008 fill_first: bool,
1009 store: bool,
1010 ) -> "LegacyParticipantType | None": ...
1012 @overload
1013 async def get_participant(
1014 self,
1015 nickname: str,
1016 *,
1017 create: bool,
1018 fill_first: bool,
1019 ) -> "LegacyParticipantType | None": ...
1021 @overload
1022 async def get_participant(
1023 self,
1024 nickname: str,
1025 *,
1026 is_user: Literal[True],
1027 fill_first: bool,
1028 store: bool,
1029 occupant_id: str | None = None,
1030 ) -> "LegacyParticipantType": ...
1032 async def get_participant(
1033 self,
1034 nickname: str | None = None,
1035 *,
1036 create: bool = True,
1037 is_user: bool = False,
1038 fill_first: bool = False,
1039 store: bool = True,
1040 occupant_id: str | None = None,
1041 ) -> "LegacyParticipantType | None":
1042 """
1043 Get a participant by their nickname.
1045 In non-anonymous groups, you probably want to use
1046 :meth:`.LegacyMUC.get_participant_by_contact` instead.
1048 :param nickname: Nickname of the participant (used as resource part in the MUC)
1049 :param create: By default, a participant is created if necessary. Set this to
1050 False to return None if participant was not created before.
1051 :param is_user: Whether this participant is the slidge user.
1052 :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called
1053 first (internal use by slidge, plugins should not need that)
1054 :param store: persistently store the user in the list of MUC participants
1055 :param occupant_id: optionally, specify the unique ID for this participant, cf
1056 xep:`0421`
1057 :return: A participant of this room.
1058 """
1059 if not any((nickname, occupant_id)):
1060 raise TypeError("You must specify either a nickname or an occupant ID")
1061 if fill_first:
1062 await self.__fill_participants()
1063 if self.stored.id is not None:
1064 with self.xmpp.store.session(expire_on_commit=False) as orm:
1065 if occupant_id is not None:
1066 stored = (
1067 orm.query(Participant)
1068 .filter(
1069 Participant.room == self.stored,
1070 Participant.occupant_id == occupant_id,
1071 )
1072 .one_or_none()
1073 )
1074 elif nickname is not None:
1075 stored = (
1076 orm.query(Participant)
1077 .filter(
1078 Participant.room == self.stored,
1079 (Participant.nickname == nickname)
1080 | (Participant.resource == nickname),
1081 )
1082 .one_or_none()
1083 )
1084 else:
1085 raise RuntimeError("NEVER")
1086 if stored is not None:
1087 if occupant_id and occupant_id != stored.occupant_id:
1088 warnings.warn(
1089 f"Occupant ID mismatch in get_participant(): {occupant_id} vs {stored.occupant_id}",
1090 )
1091 part = self.participant_from_store(stored)
1092 if occupant_id and nickname and nickname != stored.nickname:
1093 stored.nickname = nickname
1094 orm.add(stored)
1095 orm.commit()
1096 return part
1098 if not create:
1099 return None
1101 if occupant_id is None:
1102 occupant_id = "slidge-user" if is_user else str(uuid.uuid4())
1104 if nickname is None:
1105 nickname = occupant_id
1107 with self.xmpp.store.session() as orm:
1108 if not self.xmpp.store.rooms.nick_available(orm, self.stored.id, nickname):
1109 nickname = f"{nickname} ({occupant_id})"
1110 if is_user:
1111 self.user_nick = nickname
1113 p = self.participant_cls(
1114 self,
1115 Participant(
1116 room=self.stored,
1117 nickname=nickname or occupant_id,
1118 is_user=is_user,
1119 occupant_id=occupant_id,
1120 ),
1121 )
1122 if store:
1123 self.__store_participant(p)
1124 self.send_affiliation_change(p)
1125 return p
1127 def send_affiliation_change(
1128 self, part: "LegacyParticipantType", was: MucAffiliation = "none"
1129 ) -> None:
1130 # internal use by slidge
1131 if not self.participants_filled:
1132 return
1133 if self.get_lock("fill participants"):
1134 return
1135 if self.get_lock("fill history"):
1136 return
1137 if part.contact is None:
1138 return
1139 if part.is_system:
1140 return
1141 if was == part.affiliation:
1142 return
1143 system_part = self.get_system_participant()
1144 msg = system_part._make_message(mtype="normal")
1145 until = self.__get_mav_data()
1146 since = [
1147 (id_, was if id_ == self.__get_part_mav_id(part) else aff)
1148 for id_, aff in until
1149 ]
1150 if part.affiliation == "none":
1151 since.append((self.__get_part_mav_id(part), was))
1152 msg["muc"]["mav"]["since"] = self.__compute_mav_ver(since)
1153 msg["muc"]["mav"]["until"] = self.__compute_mav_ver(until)
1154 item = MUCUserItem()
1155 item["affiliation"] = part.affiliation
1156 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare
1157 msg["muc"].append(item)
1158 system_part._send(msg)
1160 def get_system_participant(self) -> "LegacyParticipantType":
1161 """
1162 Get a pseudo-participant, representing the room itself
1164 Can be useful for events that cannot be mapped to a participant,
1165 e.g. anonymous moderation events, or announces from the legacy
1166 service
1167 :return:
1168 """
1169 return self.participant_cls(
1170 self, Participant(occupant_id="room"), is_system=True
1171 )
1173 @overload
1174 async def get_participant_by_contact(
1175 self, c: "LegacyContact"
1176 ) -> "LegacyParticipantType": ...
1178 @overload
1179 async def get_participant_by_contact(
1180 self, c: "LegacyContact", *, occupant_id: str | None = None
1181 ) -> "LegacyParticipantType": ...
1183 @overload
1184 async def get_participant_by_contact(
1185 self,
1186 c: "LegacyContact",
1187 *,
1188 create: Literal[False],
1189 occupant_id: str | None,
1190 ) -> "LegacyParticipantType | None": ...
1192 @overload
1193 async def get_participant_by_contact(
1194 self,
1195 c: "LegacyContact",
1196 *,
1197 create: Literal[True],
1198 occupant_id: str | None,
1199 ) -> "LegacyParticipantType": ...
1201 async def get_participant_by_contact(
1202 self, c: LegacyContact, *, create: bool = True, occupant_id: str | None = None
1203 ) -> "LegacyParticipantType | None":
1204 """
1205 Get a non-anonymous participant.
1207 This is what should be used in non-anonymous groups ideally, to ensure
1208 that the Contact jid is associated to this participant
1210 :param c: The :class:`.LegacyContact` instance corresponding to this contact
1211 :param create: Creates the participant if it does not exist.
1212 :param occupant_id: Optionally, specify a unique occupant ID (:xep:`0421`) for
1213 this participant.
1214 :return:
1215 """
1216 await self.session.contacts.ready
1218 if self.stored.id is not None:
1219 with self.xmpp.store.session() as orm:
1220 self.stored = orm.merge(self.stored)
1221 stored = (
1222 orm.query(Participant)
1223 .filter_by(contact=c.stored, room=self.stored)
1224 .one_or_none()
1225 )
1226 if stored is None:
1227 if occupant_id is not None:
1228 stored = (
1229 orm.query(Participant)
1230 .filter_by(
1231 occupant_id=occupant_id,
1232 room=self.stored,
1233 contact_id=None,
1234 )
1235 .one_or_none()
1236 )
1237 if stored is not None:
1238 self.log.debug(
1239 "Updating the contact of a previously anonymous participant"
1240 )
1241 stored.contact_id = c.stored.id
1242 orm.add(stored)
1243 orm.commit()
1244 return self.participant_from_store(stored=stored, contact=c)
1245 if not create:
1246 return None
1247 else:
1248 if occupant_id and stored.occupant_id != occupant_id:
1249 warnings.warn(
1250 f"Occupant ID mismatch: {occupant_id} vs {stored.occupant_id}",
1251 )
1252 return self.participant_from_store(stored=stored, contact=c)
1254 nickname = c.name or unescape_node(c.jid.node)
1256 if self.stored.id is None:
1257 nick_available = True
1258 else:
1259 with self.xmpp.store.session() as orm:
1260 nick_available = self.xmpp.store.rooms.nick_available(
1261 orm, self.stored.id, nickname
1262 )
1264 if not nick_available:
1265 self.log.debug("Nickname conflict")
1266 nickname = f"{nickname} ({unescape_node(c.jid.node)})"
1267 p = self.participant_cls(
1268 self,
1269 Participant(
1270 nickname=nickname,
1271 room=self.stored,
1272 occupant_id=occupant_id or str(c.jid),
1273 ),
1274 contact=c,
1275 )
1277 self.__store_participant(p)
1278 # FIXME: this is not great but given the current design,
1279 # during participants fill and history backfill we do not
1280 # want to send presence, because we might :update affiliation
1281 # and role afterwards.
1282 # We need a refactor of the MUC class… later™
1283 if (
1284 self.participants_filled
1285 and not self.get_lock("fill participants")
1286 and not self.get_lock("fill history")
1287 ):
1288 self.send_affiliation_change(p)
1289 p.send_last_presence(force=True, no_cache_online=True)
1290 return p
1292 @overload
1293 async def get_participant_by_legacy_id(
1294 self, legacy_id: str
1295 ) -> "LegacyParticipantType": ...
1297 @overload
1298 async def get_participant_by_legacy_id(
1299 self,
1300 legacy_id: str,
1301 *,
1302 occupant_id: str | None,
1303 create: Literal[True],
1304 ) -> "LegacyParticipantType": ...
1306 @overload
1307 async def get_participant_by_legacy_id(
1308 self,
1309 legacy_id: str,
1310 *,
1311 occupant_id: str | None,
1312 ) -> "LegacyParticipantType": ...
1314 @overload
1315 async def get_participant_by_legacy_id(
1316 self,
1317 legacy_id: str,
1318 *,
1319 occupant_id: str | None,
1320 create: Literal[False],
1321 ) -> "LegacyParticipantType | None": ...
1323 async def get_participant_by_legacy_id(
1324 self,
1325 legacy_id: str,
1326 *,
1327 occupant_id: str | None = None,
1328 create: bool = True,
1329 ) -> "LegacyParticipantType":
1330 try:
1331 c = await self.session.contacts.by_legacy_id(legacy_id)
1332 except ContactIsUser:
1333 return await self.get_user_participant(occupant_id=occupant_id)
1334 return await self.get_participant_by_contact( # type:ignore[call-overload,no-any-return]
1335 c, create=create, occupant_id=occupant_id
1336 )
1338 def remove_participant(
1339 self,
1340 p: "LegacyParticipantType",
1341 kick: bool = False,
1342 ban: bool = False,
1343 reason: str | None = None,
1344 ) -> None:
1345 """
1346 Call this when a participant leaves the room
1348 :param p: The participant
1349 :param kick: Whether the participant left because they were kicked
1350 :param ban: Whether the participant left because they were banned
1351 :param reason: Optionally, a reason why the participant was removed.
1352 """
1353 self.log.debug("Removing participant: %s", p)
1354 if kick and ban:
1355 raise TypeError("Either kick or ban")
1356 if kick:
1357 codes = {307}
1358 elif ban:
1359 codes = {301}
1360 else:
1361 codes = None
1362 was = p.stored.affiliation
1363 p.stored.affiliation = "outcast" if ban else "none"
1364 p.stored.role = "none"
1365 presence = p._make_presence(ptype="unavailable", status_codes=codes)
1366 with self.orm() as orm:
1367 self.xmpp.store.participants.delete(orm, p.stored.id)
1368 orm.commit()
1369 self.send_affiliation_change(p, was)
1370 if reason:
1371 presence["muc"].set_item_attr("reason", reason)
1372 p._send(presence, force=True)
1374 def rename_participant(self, old_nickname: str, new_nickname: str) -> None:
1375 with self.xmpp.store.session() as orm:
1376 stored = (
1377 orm.query(Participant)
1378 .filter_by(room=self.stored, nickname=old_nickname)
1379 .one_or_none()
1380 )
1381 if stored is None:
1382 self.log.debug("Tried to rename a participant that we didn't know")
1383 return
1384 p = self.participant_from_store(stored)
1385 if p.nickname == old_nickname:
1386 p.nickname = new_nickname
1388 async def __old_school_history(
1389 self,
1390 full_jid: JID,
1391 maxchars: int | None = None,
1392 maxstanzas: int | None = None,
1393 seconds: int | None = None,
1394 since: datetime | None = None,
1395 ) -> None:
1396 """
1397 Old-style history join (internal slidge use)
1399 :param full_jid:
1400 :param maxchars:
1401 :param maxstanzas:
1402 :param seconds:
1403 :param since:
1404 :return:
1405 """
1406 if since is None:
1407 if seconds is None:
1408 start_date = datetime.now(tz=UTC) - timedelta(days=1)
1409 else:
1410 start_date = datetime.now(tz=UTC) - timedelta(seconds=seconds)
1411 else:
1412 start_date = since or datetime.now(tz=UTC) - timedelta(days=1)
1414 for h_msg in self.archive.get_all(
1415 start_date=start_date, end_date=None, last_page_n=maxstanzas
1416 ):
1417 msg = h_msg.stanza("jabber:component:accept", user_jid=self.user_jid.bare)
1418 msg["delay"]["stamp"] = h_msg.timestamp.replace(tzinfo=UTC)
1419 msg["delay"]["from"] = self.jid
1420 msg.set_to(full_jid)
1421 self.xmpp.send(msg, False)
1423 async def send_mam(self, iq: Iq) -> None:
1424 await self.__fill_history()
1426 form_values = iq["mam"]["form"].get_values()
1428 start_date = str_to_datetime_or_none(form_values.get("start"))
1429 end_date = str_to_datetime_or_none(form_values.get("end"))
1431 after_id = form_values.get("after-id")
1432 before_id = form_values.get("before-id")
1434 ids = form_values.get("ids") or ()
1436 if max_str := iq["mam"]["rsm"]["max"]:
1437 try:
1438 max_results = int(max_str)
1439 except ValueError:
1440 max_results = None
1441 else:
1442 max_results = None
1444 after_id_rsm = iq["mam"]["rsm"]["after"]
1445 after_id = after_id_rsm or after_id
1447 before_rsm = iq["mam"]["rsm"]["before"]
1448 if before_rsm is not None and max_results is not None:
1449 last_page_n = max_results
1450 # - before_rsm is True means the empty element <before />, which means
1451 # "last page in chronological order", cf https://xmpp.org/extensions/xep-0059.html#backwards
1452 # - before_rsm == "an ID" means <before>an ID</before>
1453 if before_rsm is not True:
1454 before_id = before_rsm
1455 else:
1456 last_page_n = None
1458 first = None
1459 last = None
1460 count = 0
1462 sender = form_values.get("with")
1463 sender_resource = JID(sender).resource if sender else None
1465 it = self.archive.get_all(
1466 start_date,
1467 end_date,
1468 before_id,
1469 after_id,
1470 ids,
1471 last_page_n,
1472 sender_resource,
1473 bool(iq["mam"]["flip_page"]),
1474 )
1476 for history_msg in it:
1477 last = xmpp_id = history_msg.stanza_id
1478 if first is None:
1479 first = xmpp_id
1481 wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from())
1482 wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"]
1483 wrapper_msg["mam_result"]["id"] = xmpp_id
1484 wrapper_msg["mam_result"].append(
1485 history_msg.forwarded(user_jid=self.user_jid.bare)
1486 )
1488 wrapper_msg.send()
1489 count += 1
1491 if max_results and count == max_results:
1492 break
1494 if max_results:
1495 try:
1496 next(it)
1497 except StopIteration:
1498 complete = True
1499 else:
1500 complete = False
1501 else:
1502 complete = True
1504 reply = iq.reply()
1505 if not self.STABLE_ARCHIVE:
1506 reply["mam_fin"]["stable"] = "false"
1507 if complete:
1508 reply["mam_fin"]["complete"] = "true"
1509 reply["mam_fin"]["rsm"]["first"] = first
1510 reply["mam_fin"]["rsm"]["last"] = last
1511 reply["mam_fin"]["rsm"]["count"] = str(count)
1512 reply.send()
1514 async def send_mam_metadata(self, iq: Iq) -> None:
1515 await self.__fill_history()
1516 await self.archive.send_metadata(iq)
1518 async def kick_resource(self, r: str) -> None:
1519 """
1520 Kick a XMPP client of the user. (slidge internal use)
1522 :param r: The resource to kick
1523 """
1524 pto = JID(self.user_jid)
1525 pto.resource = r
1526 p = self.xmpp.make_presence(
1527 pfrom=(await self.get_user_participant()).jid, pto=pto
1528 )
1529 p["type"] = "unavailable"
1530 p["muc"]["affiliation"] = "none"
1531 p["muc"]["role"] = "none"
1532 p["muc"]["status_codes"] = {110, 333}
1533 p.send()
1535 async def __get_bookmark(self) -> Item | None:
1536 item = Item()
1537 item["id"] = self.jid
1539 iq = Iq(stype="get", sfrom=self.user_jid, sto=self.user_jid)
1540 iq["pubsub"]["items"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS
1541 iq["pubsub"]["items"].append(item)
1543 try:
1544 ans = await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
1545 if len(ans["pubsub"]["items"]) != 1:
1546 return None
1547 # this below creates the item if it wasn't here already
1548 # (slixmpp annoying magic)
1549 item = ans["pubsub"]["items"]["item"]
1550 item["id"] = self.jid
1551 return item # type:ignore[no-any-return]
1552 except IqTimeout:
1553 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: timeout")
1554 return None
1555 except IqError as exc:
1556 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: {exc}")
1557 return None
1558 except PermissionError:
1559 warnings.warn(
1560 f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot fetch the user bookmarks"
1561 )
1562 return None
1564 async def add_to_bookmarks(
1565 self,
1566 auto_join: bool = True,
1567 preserve: bool = True,
1568 pin: bool | None = None,
1569 notify: WhenLiteral | None = None,
1570 ) -> None:
1571 """
1572 Add the MUC to the user's XMPP bookmarks (:xep:`0402`)
1574 This requires that slidge has the IQ privileged set correctly
1575 on the XMPP server
1577 :param auto_join: whether XMPP clients should automatically join
1578 this MUC on startup. In theory, XMPP clients will receive
1579 a "push" notification when this is called, and they will
1580 join if they are online.
1581 :param preserve: preserve auto-join and bookmarks extensions
1582 set by the user outside slidge
1583 :param pin: Pin the group chat bookmark :xep:`0469`. Requires privileged entity.
1584 If set to ``None`` (default), the bookmark pinning status will be untouched.
1585 :param notify: Chat notification setting: :xep:`0492`. Requires privileged entity.
1586 If set to ``None`` (default), the setting will be untouched. Only the "global"
1587 notification setting is supported (ie, per client type is not possible).
1588 """
1589 existing = await self.__get_bookmark() if preserve else None
1590 user_resource = (await self.get_user_participant()).jid.resource
1592 new = Item()
1593 new["id"] = self.jid
1594 new["conference"]["nick"] = user_resource
1596 invite = self.session.user.preferences.get(
1597 "always_invite_when_adding_bookmarks", True
1598 )
1599 if existing is None:
1600 change = True
1601 new["conference"]["autojoin"] = auto_join
1602 else:
1603 change = existing["conference"]["nick"] != user_resource
1604 if not existing["conference"]["autojoin"]:
1605 invite = False
1606 new["conference"]["autojoin"] = existing["conference"]["autojoin"]
1608 existing_extensions = (
1609 existing is not None and "extensions" in existing["conference"]
1610 )
1612 # preserving extensions we don't know about is a MUST
1613 if existing_extensions:
1614 assert existing is not None
1615 for el in existing["conference"]["extensions"].xml:
1616 if el.tag.startswith(f"{{{NOTIFY_NS}}}") and notify is not None:
1617 continue
1618 if el.tag.startswith(f"{{{PINNING_NS}}}") and pin is not None:
1619 continue
1620 new["conference"]["extensions"].append(el)
1622 if pin is not None:
1623 if existing_extensions:
1624 assert existing is not None
1625 existing_pin = (
1626 existing["conference"]["extensions"].get_plugin(
1627 "pinned", check=True
1628 )
1629 is not None
1630 )
1631 if existing_pin != pin:
1632 change = True
1633 new["conference"]["extensions"]["pinned"] = pin
1635 if notify is not None:
1636 new["conference"]["extensions"].enable("notify")
1637 if existing_extensions:
1638 assert existing is not None
1639 existing_notify = existing["conference"]["extensions"].get_plugin(
1640 "notify", check=True
1641 )
1642 if existing_notify is None:
1643 change = True
1644 else:
1645 if existing_notify.get_config() != notify:
1646 change = True
1647 for el in existing_notify:
1648 new["conference"]["extensions"]["notify"].append(el)
1649 new["conference"]["extensions"]["notify"].configure(notify)
1651 if change:
1652 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid)
1653 iq["pubsub"]["publish"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS
1654 iq["pubsub"]["publish"].append(new)
1656 iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS
1658 update_success = False
1659 try:
1660 await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
1661 except PermissionError:
1662 warnings.warn(
1663 f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot add bookmarks for the user"
1664 )
1665 except IqError as e:
1666 warnings.warn(
1667 f"Something went wrong while trying to set the bookmarks: {e}"
1668 )
1669 else:
1670 update_success = True
1671 if existing is None and not update_success and not invite:
1672 self.session.send_gateway_invite(
1673 self,
1674 reason="This group could not be added automatically for you, most "
1675 "likely because this gateway is not configured as a privileged entity. "
1676 "Contact your administrator.",
1677 )
1678 return
1679 else:
1680 self.log.debug("Bookmark does not need updating.")
1682 if invite:
1683 self.session.send_gateway_invite(
1684 self.jid,
1685 reason="The gateway is configured to send invitations for groups.",
1686 )
1688 async def remove_from_bookmarks(self) -> None:
1689 """Remove the MUC from the user's XMPP bookmarks (:xep:`0402`).
1691 Does not actually leave the room on the legacy network, nor apply any other
1692 side-effect.
1694 Requires that slidge has the IQ privileged set correctly on the XMPP server.
1695 """
1696 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid)
1697 iq["pubsub"]["retract"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS
1698 iq["pubsub"]["retract"]["notify"] = "true"
1699 iq["pubsub"]["retract"]["item"]["id"] = str(self.jid)
1700 try:
1701 await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
1702 except PermissionError:
1703 warnings.warn(
1704 f"IQ privileges (XEP0356) not granted for {self.user_jid}, "
1705 "we cannot remove bookmarks for the user"
1706 )
1707 except IqError as e:
1708 warnings.warn(
1709 f"Something went wrong while trying to remove a bookmark: {e}"
1710 )
1712 async def on_avatar(self, data: bytes | None, mime: str | None) -> str | None:
1713 """
1714 Called when the user tries to set the avatar of the room from an XMPP
1715 client.
1717 If the set avatar operation is completed, should return a legacy image
1718 unique identifier. In this case the MUC avatar will be immediately
1719 updated on the XMPP side.
1721 If data is not None and this method returns None, then we assume that
1722 self.set_avatar() will be called elsewhere, eg triggered by a legacy
1723 room update event.
1725 :param data: image data or None if the user meant to remove the avatar
1726 :param mime: the mime type of the image. Since this is provided by
1727 the XMPP client, there is no guarantee that this is valid or
1728 correct.
1729 :return: A unique avatar identifier, which will trigger
1730 :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if
1731 :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else.
1732 """
1733 raise NotImplementedError
1735 async def on_set_config(
1736 self,
1737 name: str | None,
1738 description: str | None,
1739 ) -> None:
1740 """
1741 Triggered when the user requests changing the room configuration.
1742 Only title and description can be changed at the moment.
1744 The legacy module is responsible for updating :attr:`.title` and/or
1745 :attr:`LegacyMUC.description` of this instance.
1747 If :attr:`.HAS_DESCRIPTION` is set to False, description will always
1748 be ``None``.
1750 :param name: The new name of the room.
1751 :param description: The new description of the room.
1752 """
1753 raise NotImplementedError
1755 async def on_destroy_request(self, reason: str | None) -> None:
1756 """
1757 Triggered when the user requests room destruction.
1759 :param reason: Optionally, a reason for the destruction
1760 """
1761 raise NotImplementedError
1763 async def parse_mentions(
1764 self, text: str | None
1765 ) -> tuple[Mention[LegacyParticipantType], ...]:
1766 if not text:
1767 return ()
1768 with self.xmpp.store.session() as orm:
1769 await self.__fill_participants()
1770 orm.add(self.stored)
1771 participants = {
1772 p.nickname: p for p in self.stored.participants if len(p.nickname) > 1
1773 }
1775 if len(participants) == 0:
1776 return ()
1778 result = []
1779 for match in re.finditer(
1780 "|".join(
1781 sorted(
1782 [re.escape(nick) for nick in participants],
1783 key=lambda nick: len(nick),
1784 reverse=True,
1785 )
1786 ),
1787 text,
1788 ):
1789 span = match.span()
1790 nick = match.group()
1791 if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION:
1792 continue
1793 if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION:
1794 participant = self.participant_from_store(stored=participants[nick])
1795 result.append(
1796 Mention(participant=participant, start=span[0], end=span[1])
1797 )
1798 return tuple(result)
1800 async def on_set_subject(self, subject: str) -> None:
1801 """
1802 Triggered when the user requests changing the room subject.
1804 The legacy module is responsible for updating :attr:`.subject` of this
1805 instance.
1807 :param subject: The new subject for this room.
1808 """
1809 raise NotImplementedError
1811 async def on_set_thread_subject(self, thread: str, subject: str) -> None:
1812 """
1813 Triggered when the user requests changing the subject of a specific thread.
1815 :param thread: Legacy identifier of the thread
1816 :param subject: The new subject for this thread.
1817 """
1818 raise NotImplementedError
1820 async def on_moderate(self, legacy_msg_id: str, reason: str | None) -> None:
1821 """
1822 Triggered when the user attempts to retract a message that was sent in
1823 a MUC using :xep:`0425`.
1825 If retraction is not possible, this should raise the appropriate
1826 XMPPError with a human-readable message.
1828 NB: the legacy module is responsible for calling
1829 :func:`LegacyParticipant.moderate` when this is successful, because
1830 slidge will acknowledge the moderation IQ, but will not send the
1831 moderation message from the MUC automatically.
1833 :param legacy_msg_id: The legacy ID of the message to be retracted
1834 :param reason: Optionally, a reason for the moderation, given by the
1835 user-moderator.
1836 """
1837 raise NotImplementedError
1839 async def on_leave(self) -> None:
1840 """
1841 Triggered when the user leaves a group via the dedicated slidge command
1842 or the :xep:`0077` ``<remove />`` mechanism.
1844 This should be interpreted as definitely leaving the group.
1845 """
1846 raise NotImplementedError
1848 @property
1849 def participants_filled(self) -> bool:
1850 # We don't store anything about participants before fill_participants()
1851 # has been called…
1852 if type(self).fill_participants is LegacyMUC.fill_participants:
1853 # …except if concrete MUC implementations do not override the
1854 # default no-op implementation.
1855 return True
1856 try:
1857 return self.stored.participants_filled
1858 except DetachedInstanceError:
1859 with self.orm(expire_on_commit=False) as orm:
1860 orm.add(self.stored)
1861 with orm.no_autoflush:
1862 orm.refresh(self.stored, ["participants_filled"])
1863 return self.stored.participants_filled
1865 def get_archived_messages(
1866 self, msg_id: str | list[str]
1867 ) -> Iterator[ArchivedMessage]:
1868 """
1869 Query the slidge archive for messages sent in this group
1871 :param msg_id: Message ID of the message in question. Can be either a legacy ID
1872 or an XMPP ID.
1873 :return: Iterator over messages. A single legacy ID can map to several messages,
1874 because of multi-attachment messages.
1875 """
1876 with self.xmpp.store.session() as orm:
1877 yield from self.xmpp.store.mam.get_messages(
1878 orm,
1879 self.stored.id,
1880 ids=[str(msg_id)] if isinstance(msg_id, str) else msg_id,
1881 )
1884def set_origin_id(msg: Message, origin_id: str) -> None:
1885 sub = ET.Element("{urn:xmpp:sid:0}origin-id")
1886 sub.attrib["id"] = origin_id
1887 msg.xml.append(sub)
1890def int_or_none(x: str) -> int | None:
1891 try:
1892 return int(x)
1893 except ValueError:
1894 return None
1897def equals_zero(x: int | None) -> bool:
1898 if x is None:
1899 return False
1900 else:
1901 return x == 0
1904def str_to_datetime_or_none(date: str | None) -> datetime | None:
1905 if date is None:
1906 return None
1907 try:
1908 return str_to_datetime(date)
1909 except ValueError:
1910 return None
1913def bookmarks_form() -> Form:
1914 form = Form()
1915 form["type"] = "submit"
1916 form.add_field(
1917 "FORM_TYPE",
1918 value="http://jabber.org/protocol/pubsub#publish-options",
1919 ftype="hidden",
1920 )
1921 form.add_field("pubsub#persist_items", value="1")
1922 form.add_field("pubsub#max_items", value="max")
1923 form.add_field("pubsub#send_last_published_item", value="never")
1924 form.add_field("pubsub#access_model", value="whitelist")
1925 return form
1928LegacyMUC.participant_cls = LegacyParticipant # type:ignore[misc]
1930_BOOKMARKS_OPTIONS = bookmarks_form()
1931_WHITESPACE_OR_PUNCTUATION = string.whitespace + "!\"'(),.:;?@_"
1933log = logging.getLogger(__name__)