Coverage for slidge/group/room.py: 89%
971 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-29 05:05 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-29 05:05 +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 = await self.session.bookmarks.space_legacy_id_to_node(
695 stored.legacy_id
696 )
697 iri = f"xmpp:{self.xmpp.boundjid.bare}?node={node}"
698 form.add_field("muc#roominfo_pubsub", value=iri)
699 space_form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
700 space_form.add_field("FORM_TYPE", "hidden", value="urn:xmpp:spaces:0")
701 space_form.add_field("parent", label="Space parent", value=iri)
703 return r
705 def shutdown(self) -> None:
706 _, user_jid = escape_nickname(self.jid, self.user_nick)
707 for user_full_jid in self.user_full_jids():
708 presence = self.xmpp.make_presence(
709 pfrom=user_jid, pto=user_full_jid, ptype="unavailable"
710 )
711 presence["muc"]["affiliation"] = "none"
712 presence["muc"]["role"] = "none"
713 presence["muc"]["status_codes"] = {110, 332}
714 presence.send()
716 def user_full_jids(self) -> Iterable[JID]:
717 for r in self.get_user_resources():
718 j = JID(self.user_jid)
719 j.resource = r
720 yield j
722 @property
723 def user_muc_jid(self) -> JID:
724 _, user_muc_jid = escape_nickname(self.jid, self.user_nick)
725 return user_muc_jid
727 async def echo(self, msg: Message, legacy_msg_id: str | None = None) -> str:
728 msg.set_from(self.user_muc_jid)
729 if legacy_msg_id:
730 msg["stanza_id"]["id"] = legacy_msg_id
731 else:
732 msg["stanza_id"]["id"] = str(uuid.uuid4())
733 msg["stanza_id"]["by"] = self.jid
735 user_part = await self.get_user_participant()
736 msg["occupant-id"]["id"] = user_part.stored.occupant_id
738 self.archive.add(msg, user_part, legacy_msg_id=legacy_msg_id)
740 for user_full_jid in self.user_full_jids():
741 self.log.debug("Echoing to %s", user_full_jid)
742 msg = copy(msg)
743 msg.set_to(user_full_jid)
745 msg.send()
747 return msg["stanza_id"]["id"] # type:ignore[no-any-return]
749 def _post_avatar_update(self, cached_avatar: "CachedAvatar | None") -> None:
750 self.__send_configuration_change((104,))
751 self._send_room_presence()
753 def _send_room_presence(self, user_full_jid: JID | None = None) -> None:
754 tos = self.user_full_jids() if user_full_jid is None else [user_full_jid]
755 for to in tos:
756 p = self.xmpp.make_presence(pfrom=self.jid, pto=to)
757 if (avatar := self.get_avatar()) and (h := avatar.id):
758 p["vcard_temp_update"]["photo"] = h
759 if avatar.http_metadata is not None:
760 metadata = self.xmpp.plugin["xep_0084"].stanza.MetaData()
761 metadata.append(avatar.http_metadata)
762 p.append(metadata)
763 else:
764 p["vcard_temp_update"]["photo"] = ""
765 p.send()
767 @timeit
768 async def join(self, join_presence: Presence) -> None:
769 user_full_jid = join_presence.get_from()
770 requested_nickname = join_presence.get_to().resource
771 client_resource = user_full_jid.resource
773 if client_resource in self.get_user_resources():
774 self.log.debug("Received join from a resource that is already joined.")
776 if not requested_nickname or not client_resource:
777 raise XMPPError("jid-malformed", by=self.jid)
779 self.add_user_resource(client_resource)
781 self.log.debug(
782 "Resource %s of %s wants to join room %s with nickname %s",
783 client_resource,
784 self.user_jid,
785 self.legacy_id,
786 requested_nickname,
787 )
789 await self.__fill_participants()
790 if "mav" in join_presence["muc_join"]:
791 mav_until = await self.__get_mav()
792 self.log.debug("client uses MUC affiliation versioning")
793 if join_presence["muc_join"]["mav"]["since"] != mav_until:
794 self.log.debug(
795 "client mav: %s vs our mav: %s",
796 join_presence["muc_join"]["mav"]["since"],
797 mav_until,
798 )
799 await self.__send_mav(user_full_jid, mav_until)
800 else:
801 mav_until = None
803 self.log.debug("Sending presences for join of %s", client_resource)
805 self.stored.load_participants(self.xmpp.store.session, with_presence=True)
806 user_participant = None
808 part_pks = []
809 for db_participant in self.stored.participants:
810 participant = self.participant_from_store(db_participant, for_join=True)
811 if participant.is_user:
812 user_participant = participant
813 continue
814 participant.send_initial_presence(full_jid=user_full_jid)
815 part_pks.append(db_participant.id)
817 with self.xmpp.store.session(expire_on_commit=False) as orm:
818 orm.execute(
819 sa.update(Participant)
820 .where(Participant.id.in_(part_pks))
821 .values(presence_sent=True)
822 )
823 orm.commit()
825 if user_participant is None:
826 user_participant = await self.get_user_participant()
827 with self.xmpp.store.session() as orm:
828 orm.add(self.stored)
829 with orm.no_autoflush:
830 orm.refresh(self.stored, ["participants"])
831 if not user_participant.is_user:
832 self.log.warning("is_user flag not set on user_participant")
833 user_participant.is_user = True
834 user_participant.send_initial_presence(
835 user_full_jid,
836 presence_id=join_presence["id"],
837 nick_change=self.user_nick != requested_nickname,
838 mav_until=mav_until,
839 )
841 self.log.debug("Sending presences for join of %s: DONE", client_resource)
843 history_params = join_presence["muc_join"]["history"]
844 maxchars = int_or_none(history_params["maxchars"])
845 maxstanzas = int_or_none(history_params["maxstanzas"])
846 seconds = int_or_none(history_params["seconds"])
847 try:
848 since = self.xmpp.plugin["xep_0082"].parse(history_params["since"])
849 except ValueError:
850 since = None
851 if seconds is not None:
852 since = datetime.now(tz=UTC) - timedelta(seconds=seconds)
853 if equals_zero(maxchars) or equals_zero(maxstanzas):
854 log.debug("Joining client does not want any old-school MUC history-on-join")
855 else:
856 self.log.debug("Old school history fill")
857 await self.__fill_history()
858 await self.__old_school_history(
859 user_full_jid,
860 maxchars=maxchars,
861 maxstanzas=maxstanzas,
862 since=since,
863 )
864 if self.HAS_SUBJECT:
865 subject = self.subject or ""
866 else:
867 subject = self.description or self.name or ""
868 self.__get_subject_setter_participant().set_room_subject(
869 subject,
870 user_full_jid,
871 self.subject_date,
872 )
873 if t := self._set_avatar_task:
874 await t
875 self._send_room_presence(user_full_jid)
877 async def __get_mav(self) -> str:
878 data = self.__get_mav_data()
879 return self.__compute_mav_ver(data)
881 def __get_mav_data(self) -> list[tuple[str, MucAffiliation]]:
882 data: list[tuple[str, MucAffiliation]] = []
883 self.stored.load_participants(self.orm, with_presence=False)
884 for part in self.stored.participants:
885 if not (part.is_user or part.contact):
886 continue
887 if part.affiliation == "none":
888 continue
889 data.append((self.__get_part_mav_id(part), part.affiliation))
890 return data
892 def __get_part_mav_id(self, part: "LegacyParticipantType | Participant") -> str:
893 return str(self.user_jid if part.is_user else part.contact.legacy_id) # type:ignore[union-attr] # ty:ignore[unresolved-attribute]
895 def __compute_mav_ver(self, data: list[tuple[Any, MucAffiliation]]) -> str:
896 self.log.debug("MAV data: %s", data)
897 affs = []
898 for id_, aff in data:
899 if aff == "none":
900 continue
901 affs.append(f"{id_}\0{aff}".encode())
902 affs.sort()
903 return hashlib.sha256(b"\0".join(affs)).hexdigest()
905 async def __send_mav(self, full_jid: JID, until: str) -> None:
906 msg = self.xmpp.make_message(mto=full_jid, mfrom=self.jid)
907 msg["muc"]["mav"]["until"] = until
908 with self.orm(expire_on_commit=False) as orm:
909 self.stored = orm.merge(self.stored)
910 for part in self.stored.participants:
911 if not (part.is_user or part.contact):
912 continue
913 item = MUCUserItem()
914 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare # type:ignore[union-attr]
915 item["affiliation"] = part.affiliation
916 msg["muc"].append(item)
917 msg.send()
919 async def get_user_participant(
920 self,
921 *,
922 fill_first: bool = False,
923 store: bool = True,
924 occupant_id: str | None = None,
925 ) -> "LegacyParticipantType":
926 """
927 Get the participant representing the gateway user
929 :param kwargs: additional parameters for the :class:`.Participant`
930 construction (optional)
931 :return:
932 """
933 p = await self.get_participant(
934 self.user_nick,
935 is_user=True,
936 fill_first=fill_first,
937 store=store,
938 occupant_id=occupant_id,
939 )
940 self.__store_participant(p)
941 return p
943 def __store_participant(self, p: "LegacyParticipantType") -> None:
944 if self.get_lock("fill participants"):
945 return
946 try:
947 p.commit()
948 except IntegrityError as e:
949 log.debug("Could not store participant: %r", e)
951 @overload
952 async def get_participant(self, nickname: str) -> "LegacyParticipantType": ...
954 @overload
955 async def get_participant(
956 self, nickname: str, *, store: bool
957 ) -> "LegacyParticipantType": ...
959 @overload
960 async def get_participant(self, *, occupant_id: str) -> "LegacyParticipantType": ...
962 @overload
963 async def get_participant(
964 self, *, occupant_id: str, create: Literal[False]
965 ) -> "LegacyParticipantType | None": ...
967 @overload
968 async def get_participant(
969 self, nickname: str, *, occupant_id: str, create: bool
970 ) -> "LegacyParticipantType | None": ...
972 @overload
973 async def get_participant(
974 self, *, occupant_id: str, create: Literal[True]
975 ) -> "LegacyParticipantType": ...
977 @overload
978 async def get_participant(
979 self, nickname: str, *, occupant_id: str
980 ) -> "LegacyParticipantType": ...
982 @overload
983 async def get_participant(
984 self, nickname: str, *, create: Literal[False]
985 ) -> "LegacyParticipantType | None": ...
987 @overload
988 async def get_participant(
989 self, nickname: str, *, create: Literal[True]
990 ) -> "LegacyParticipantType": ...
992 @overload
993 async def get_participant(
994 self,
995 nickname: str,
996 *,
997 create: Literal[True],
998 is_user: bool,
999 fill_first: bool,
1000 store: bool,
1001 ) -> "LegacyParticipantType": ...
1003 @overload
1004 async def get_participant(
1005 self,
1006 nickname: str,
1007 *,
1008 create: Literal[False],
1009 is_user: bool,
1010 fill_first: bool,
1011 store: bool,
1012 ) -> "LegacyParticipantType | None": ...
1014 @overload
1015 async def get_participant(
1016 self,
1017 nickname: str,
1018 *,
1019 create: bool,
1020 fill_first: bool,
1021 ) -> "LegacyParticipantType | None": ...
1023 @overload
1024 async def get_participant(
1025 self,
1026 nickname: str,
1027 *,
1028 is_user: Literal[True],
1029 fill_first: bool,
1030 store: bool,
1031 occupant_id: str | None = None,
1032 ) -> "LegacyParticipantType": ...
1034 async def get_participant(
1035 self,
1036 nickname: str | None = None,
1037 *,
1038 create: bool = True,
1039 is_user: bool = False,
1040 fill_first: bool = False,
1041 store: bool = True,
1042 occupant_id: str | None = None,
1043 ) -> "LegacyParticipantType | None":
1044 """
1045 Get a participant by their nickname.
1047 In non-anonymous groups, you probably want to use
1048 :meth:`.LegacyMUC.get_participant_by_contact` instead.
1050 :param nickname: Nickname of the participant (used as resource part in the MUC)
1051 :param create: By default, a participant is created if necessary. Set this to
1052 False to return None if participant was not created before.
1053 :param is_user: Whether this participant is the slidge user.
1054 :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called
1055 first (internal use by slidge, plugins should not need that)
1056 :param store: persistently store the user in the list of MUC participants
1057 :param occupant_id: optionally, specify the unique ID for this participant, cf
1058 xep:`0421`
1059 :return: A participant of this room.
1060 """
1061 if not any((nickname, occupant_id)):
1062 raise TypeError("You must specify either a nickname or an occupant ID")
1063 if fill_first:
1064 await self.__fill_participants()
1065 if self.stored.id is not None:
1066 with self.xmpp.store.session(expire_on_commit=False) as orm:
1067 if occupant_id is not None:
1068 stored = (
1069 orm.query(Participant)
1070 .filter(
1071 Participant.room == self.stored,
1072 Participant.occupant_id == occupant_id,
1073 )
1074 .one_or_none()
1075 )
1076 elif nickname is not None:
1077 stored = (
1078 orm.query(Participant)
1079 .filter(
1080 Participant.room == self.stored,
1081 (Participant.nickname == nickname)
1082 | (Participant.resource == nickname),
1083 )
1084 .one_or_none()
1085 )
1086 else:
1087 raise RuntimeError("NEVER")
1088 if stored is not None:
1089 if occupant_id and occupant_id != stored.occupant_id:
1090 warnings.warn(
1091 f"Occupant ID mismatch in get_participant(): {occupant_id} vs {stored.occupant_id}",
1092 )
1093 part = self.participant_from_store(stored)
1094 if occupant_id and nickname and nickname != stored.nickname:
1095 stored.nickname = nickname
1096 orm.add(stored)
1097 orm.commit()
1098 return part
1100 if not create:
1101 return None
1103 if occupant_id is None:
1104 occupant_id = "slidge-user" if is_user else str(uuid.uuid4())
1106 if nickname is None:
1107 nickname = occupant_id
1109 with self.xmpp.store.session() as orm:
1110 if not self.xmpp.store.rooms.nick_available(orm, self.stored.id, nickname):
1111 nickname = f"{nickname} ({occupant_id})"
1112 if is_user:
1113 self.user_nick = nickname
1115 p = self.participant_cls(
1116 self,
1117 Participant(
1118 room=self.stored,
1119 nickname=nickname or occupant_id,
1120 is_user=is_user,
1121 occupant_id=occupant_id,
1122 ),
1123 )
1124 if store:
1125 self.__store_participant(p)
1126 self.send_affiliation_change(p)
1127 return p
1129 def send_affiliation_change(
1130 self, part: "LegacyParticipantType", was: MucAffiliation = "none"
1131 ) -> None:
1132 # internal use by slidge
1133 if not self.participants_filled:
1134 return
1135 if self.get_lock("fill participants"):
1136 return
1137 if self.get_lock("fill history"):
1138 return
1139 if part.contact is None:
1140 return
1141 if part.is_system:
1142 return
1143 if was == part.affiliation:
1144 return
1145 system_part = self.get_system_participant()
1146 msg = system_part._make_message(mtype="normal")
1147 until = self.__get_mav_data()
1148 since = [
1149 (id_, was if id_ == self.__get_part_mav_id(part) else aff)
1150 for id_, aff in until
1151 ]
1152 if part.affiliation == "none":
1153 since.append((self.__get_part_mav_id(part), was))
1154 msg["muc"]["mav"]["since"] = self.__compute_mav_ver(since)
1155 msg["muc"]["mav"]["until"] = self.__compute_mav_ver(until)
1156 item = MUCUserItem()
1157 item["affiliation"] = part.affiliation
1158 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare
1159 msg["muc"].append(item)
1160 system_part._send(msg)
1162 def get_system_participant(self) -> "LegacyParticipantType":
1163 """
1164 Get a pseudo-participant, representing the room itself
1166 Can be useful for events that cannot be mapped to a participant,
1167 e.g. anonymous moderation events, or announces from the legacy
1168 service
1169 :return:
1170 """
1171 return self.participant_cls(
1172 self, Participant(occupant_id="room"), is_system=True
1173 )
1175 @overload
1176 async def get_participant_by_contact(
1177 self, c: "LegacyContact"
1178 ) -> "LegacyParticipantType": ...
1180 @overload
1181 async def get_participant_by_contact(
1182 self, c: "LegacyContact", *, occupant_id: str | None = None
1183 ) -> "LegacyParticipantType": ...
1185 @overload
1186 async def get_participant_by_contact(
1187 self,
1188 c: "LegacyContact",
1189 *,
1190 create: Literal[False],
1191 occupant_id: str | None,
1192 ) -> "LegacyParticipantType | None": ...
1194 @overload
1195 async def get_participant_by_contact(
1196 self,
1197 c: "LegacyContact",
1198 *,
1199 create: Literal[True],
1200 occupant_id: str | None,
1201 ) -> "LegacyParticipantType": ...
1203 async def get_participant_by_contact(
1204 self, c: LegacyContact, *, create: bool = True, occupant_id: str | None = None
1205 ) -> "LegacyParticipantType | None":
1206 """
1207 Get a non-anonymous participant.
1209 This is what should be used in non-anonymous groups ideally, to ensure
1210 that the Contact jid is associated to this participant
1212 :param c: The :class:`.LegacyContact` instance corresponding to this contact
1213 :param create: Creates the participant if it does not exist.
1214 :param occupant_id: Optionally, specify a unique occupant ID (:xep:`0421`) for
1215 this participant.
1216 :return:
1217 """
1218 await self.session.contacts.ready
1220 if self.stored.id is not None:
1221 with self.xmpp.store.session() as orm:
1222 self.stored = orm.merge(self.stored)
1223 stored = (
1224 orm.query(Participant)
1225 .filter_by(contact=c.stored, room=self.stored)
1226 .one_or_none()
1227 )
1228 if stored is None:
1229 if occupant_id is not None:
1230 stored = (
1231 orm.query(Participant)
1232 .filter_by(
1233 occupant_id=occupant_id,
1234 room=self.stored,
1235 contact_id=None,
1236 )
1237 .one_or_none()
1238 )
1239 if stored is not None:
1240 self.log.debug(
1241 "Updating the contact of a previously anonymous participant"
1242 )
1243 stored.contact_id = c.stored.id
1244 orm.add(stored)
1245 orm.commit()
1246 return self.participant_from_store(stored=stored, contact=c)
1247 if not create:
1248 return None
1249 else:
1250 if occupant_id and stored.occupant_id != occupant_id:
1251 warnings.warn(
1252 f"Occupant ID mismatch: {occupant_id} vs {stored.occupant_id}",
1253 )
1254 return self.participant_from_store(stored=stored, contact=c)
1256 nickname = c.name or unescape_node(c.jid.node)
1258 if self.stored.id is None:
1259 nick_available = True
1260 else:
1261 with self.xmpp.store.session() as orm:
1262 nick_available = self.xmpp.store.rooms.nick_available(
1263 orm, self.stored.id, nickname
1264 )
1266 if not nick_available:
1267 self.log.debug("Nickname conflict")
1268 nickname = f"{nickname} ({unescape_node(c.jid.node)})"
1269 p = self.participant_cls(
1270 self,
1271 Participant(
1272 nickname=nickname,
1273 room=self.stored,
1274 occupant_id=occupant_id or str(c.jid),
1275 ),
1276 contact=c,
1277 )
1279 self.__store_participant(p)
1280 # FIXME: this is not great but given the current design,
1281 # during participants fill and history backfill we do not
1282 # want to send presence, because we might :update affiliation
1283 # and role afterwards.
1284 # We need a refactor of the MUC class… later™
1285 if (
1286 self.participants_filled
1287 and not self.get_lock("fill participants")
1288 and not self.get_lock("fill history")
1289 ):
1290 self.send_affiliation_change(p)
1291 p.send_last_presence(force=True, no_cache_online=True)
1292 return p
1294 @overload
1295 async def get_participant_by_legacy_id(
1296 self, legacy_id: str
1297 ) -> "LegacyParticipantType": ...
1299 @overload
1300 async def get_participant_by_legacy_id(
1301 self,
1302 legacy_id: str,
1303 *,
1304 occupant_id: str | None,
1305 create: Literal[True],
1306 ) -> "LegacyParticipantType": ...
1308 @overload
1309 async def get_participant_by_legacy_id(
1310 self,
1311 legacy_id: str,
1312 *,
1313 occupant_id: str | None,
1314 ) -> "LegacyParticipantType": ...
1316 @overload
1317 async def get_participant_by_legacy_id(
1318 self,
1319 legacy_id: str,
1320 *,
1321 occupant_id: str | None,
1322 create: Literal[False],
1323 ) -> "LegacyParticipantType | None": ...
1325 async def get_participant_by_legacy_id(
1326 self,
1327 legacy_id: str,
1328 *,
1329 occupant_id: str | None = None,
1330 create: bool = True,
1331 ) -> "LegacyParticipantType":
1332 try:
1333 c = await self.session.contacts.by_legacy_id(legacy_id)
1334 except ContactIsUser:
1335 return await self.get_user_participant(occupant_id=occupant_id)
1336 return await self.get_participant_by_contact( # type:ignore[call-overload,no-any-return]
1337 c, create=create, occupant_id=occupant_id
1338 )
1340 def remove_participant(
1341 self,
1342 p: "LegacyParticipantType",
1343 kick: bool = False,
1344 ban: bool = False,
1345 reason: str | None = None,
1346 ) -> None:
1347 """
1348 Call this when a participant leaves the room
1350 :param p: The participant
1351 :param kick: Whether the participant left because they were kicked
1352 :param ban: Whether the participant left because they were banned
1353 :param reason: Optionally, a reason why the participant was removed.
1354 """
1355 self.log.debug("Removing participant: %s", p)
1356 if kick and ban:
1357 raise TypeError("Either kick or ban")
1358 if kick:
1359 codes = {307}
1360 elif ban:
1361 codes = {301}
1362 else:
1363 codes = None
1364 was = p.stored.affiliation
1365 p.stored.affiliation = "outcast" if ban else "none"
1366 p.stored.role = "none"
1367 presence = p._make_presence(ptype="unavailable", status_codes=codes)
1368 with self.orm() as orm:
1369 self.xmpp.store.participants.delete(orm, p.stored.id)
1370 orm.commit()
1371 self.send_affiliation_change(p, was)
1372 if reason:
1373 presence["muc"].set_item_attr("reason", reason)
1374 p._send(presence, force=True)
1376 def rename_participant(self, old_nickname: str, new_nickname: str) -> None:
1377 with self.xmpp.store.session() as orm:
1378 stored = (
1379 orm.query(Participant)
1380 .filter_by(room=self.stored, nickname=old_nickname)
1381 .one_or_none()
1382 )
1383 if stored is None:
1384 self.log.debug("Tried to rename a participant that we didn't know")
1385 return
1386 p = self.participant_from_store(stored)
1387 if p.nickname == old_nickname:
1388 p.nickname = new_nickname
1390 async def __old_school_history(
1391 self,
1392 full_jid: JID,
1393 maxchars: int | None = None,
1394 maxstanzas: int | None = None,
1395 seconds: int | None = None,
1396 since: datetime | None = None,
1397 ) -> None:
1398 """
1399 Old-style history join (internal slidge use)
1401 :param full_jid:
1402 :param maxchars:
1403 :param maxstanzas:
1404 :param seconds:
1405 :param since:
1406 :return:
1407 """
1408 if since is None:
1409 if seconds is None:
1410 start_date = datetime.now(tz=UTC) - timedelta(days=1)
1411 else:
1412 start_date = datetime.now(tz=UTC) - timedelta(seconds=seconds)
1413 else:
1414 start_date = since or datetime.now(tz=UTC) - timedelta(days=1)
1416 for h_msg in self.archive.get_all(
1417 start_date=start_date, end_date=None, last_page_n=maxstanzas
1418 ):
1419 msg = h_msg.stanza("jabber:component:accept", user_jid=self.user_jid.bare)
1420 msg["delay"]["stamp"] = h_msg.timestamp.replace(tzinfo=UTC)
1421 msg["delay"]["from"] = self.jid
1422 msg.set_to(full_jid)
1423 self.xmpp.send(msg, False)
1425 async def send_mam(self, iq: Iq) -> None:
1426 await self.__fill_history()
1428 form_values = iq["mam"]["form"].get_values()
1430 start_date = str_to_datetime_or_none(form_values.get("start"))
1431 end_date = str_to_datetime_or_none(form_values.get("end"))
1433 after_id = form_values.get("after-id")
1434 before_id = form_values.get("before-id")
1436 ids = form_values.get("ids") or ()
1438 if max_str := iq["mam"]["rsm"]["max"]:
1439 try:
1440 max_results = int(max_str)
1441 except ValueError:
1442 max_results = None
1443 else:
1444 max_results = None
1446 after_id_rsm = iq["mam"]["rsm"]["after"]
1447 after_id = after_id_rsm or after_id
1449 before_rsm = iq["mam"]["rsm"]["before"]
1450 if before_rsm is not None and max_results is not None:
1451 last_page_n = max_results
1452 # - before_rsm is True means the empty element <before />, which means
1453 # "last page in chronological order", cf https://xmpp.org/extensions/xep-0059.html#backwards
1454 # - before_rsm == "an ID" means <before>an ID</before>
1455 if before_rsm is not True:
1456 before_id = before_rsm
1457 else:
1458 last_page_n = None
1460 first = None
1461 last = None
1462 count = 0
1464 sender = form_values.get("with")
1465 sender_resource = JID(sender).resource if sender else None
1467 it = self.archive.get_all(
1468 start_date,
1469 end_date,
1470 before_id,
1471 after_id,
1472 ids,
1473 last_page_n,
1474 sender_resource,
1475 bool(iq["mam"]["flip_page"]),
1476 )
1478 for history_msg in it:
1479 last = xmpp_id = history_msg.stanza_id
1480 if first is None:
1481 first = xmpp_id
1483 wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from())
1484 wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"]
1485 wrapper_msg["mam_result"]["id"] = xmpp_id
1486 wrapper_msg["mam_result"].append(
1487 history_msg.forwarded(user_jid=self.user_jid.bare)
1488 )
1490 wrapper_msg.send()
1491 count += 1
1493 if max_results and count == max_results:
1494 break
1496 if max_results:
1497 try:
1498 next(it)
1499 except StopIteration:
1500 complete = True
1501 else:
1502 complete = False
1503 else:
1504 complete = True
1506 reply = iq.reply()
1507 if not self.STABLE_ARCHIVE:
1508 reply["mam_fin"]["stable"] = "false"
1509 if complete:
1510 reply["mam_fin"]["complete"] = "true"
1511 reply["mam_fin"]["rsm"]["first"] = first
1512 reply["mam_fin"]["rsm"]["last"] = last
1513 reply["mam_fin"]["rsm"]["count"] = str(count)
1514 reply.send()
1516 async def send_mam_metadata(self, iq: Iq) -> None:
1517 await self.__fill_history()
1518 await self.archive.send_metadata(iq)
1520 async def kick_resource(self, r: str) -> None:
1521 """
1522 Kick a XMPP client of the user. (slidge internal use)
1524 :param r: The resource to kick
1525 """
1526 pto = JID(self.user_jid)
1527 pto.resource = r
1528 p = self.xmpp.make_presence(
1529 pfrom=(await self.get_user_participant()).jid, pto=pto
1530 )
1531 p["type"] = "unavailable"
1532 p["muc"]["affiliation"] = "none"
1533 p["muc"]["role"] = "none"
1534 p["muc"]["status_codes"] = {110, 333}
1535 p.send()
1537 async def __get_bookmark(self) -> Item | None:
1538 item = Item()
1539 item["id"] = self.jid
1541 iq = Iq(stype="get", sfrom=self.user_jid, sto=self.user_jid)
1542 iq["pubsub"]["items"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS
1543 iq["pubsub"]["items"].append(item)
1545 try:
1546 ans = await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
1547 if len(ans["pubsub"]["items"]) != 1:
1548 return None
1549 # this below creates the item if it wasn't here already
1550 # (slixmpp annoying magic)
1551 item = ans["pubsub"]["items"]["item"]
1552 item["id"] = self.jid
1553 return item # type:ignore[no-any-return]
1554 except IqTimeout:
1555 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: timeout")
1556 return None
1557 except IqError as exc:
1558 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: {exc}")
1559 return None
1560 except PermissionError:
1561 warnings.warn(
1562 f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot fetch the user bookmarks"
1563 )
1564 return None
1566 async def add_to_bookmarks(
1567 self,
1568 auto_join: bool = True,
1569 preserve: bool = True,
1570 pin: bool | None = None,
1571 notify: WhenLiteral | None = None,
1572 ) -> None:
1573 """
1574 Add the MUC to the user's XMPP bookmarks (:xep:`0402`)
1576 This requires that slidge has the IQ privileged set correctly
1577 on the XMPP server
1579 :param auto_join: whether XMPP clients should automatically join
1580 this MUC on startup. In theory, XMPP clients will receive
1581 a "push" notification when this is called, and they will
1582 join if they are online.
1583 :param preserve: preserve auto-join and bookmarks extensions
1584 set by the user outside slidge
1585 :param pin: Pin the group chat bookmark :xep:`0469`. Requires privileged entity.
1586 If set to ``None`` (default), the bookmark pinning status will be untouched.
1587 :param notify: Chat notification setting: :xep:`0492`. Requires privileged entity.
1588 If set to ``None`` (default), the setting will be untouched. Only the "global"
1589 notification setting is supported (ie, per client type is not possible).
1590 """
1591 existing = await self.__get_bookmark() if preserve else None
1592 user_resource = (await self.get_user_participant()).jid.resource
1594 new = Item()
1595 new["id"] = self.jid
1596 new["conference"]["nick"] = user_resource
1598 invite = self.session.user.preferences.get(
1599 "always_invite_when_adding_bookmarks", True
1600 )
1601 if existing is None:
1602 change = True
1603 new["conference"]["autojoin"] = auto_join
1604 else:
1605 change = existing["conference"]["nick"] != user_resource
1606 if not existing["conference"]["autojoin"]:
1607 invite = False
1608 new["conference"]["autojoin"] = existing["conference"]["autojoin"]
1610 existing_extensions = (
1611 existing is not None and "extensions" in existing["conference"]
1612 )
1614 # preserving extensions we don't know about is a MUST
1615 if existing_extensions:
1616 assert existing is not None
1617 for el in existing["conference"]["extensions"].xml:
1618 if el.tag.startswith(f"{{{NOTIFY_NS}}}") and notify is not None:
1619 continue
1620 if el.tag.startswith(f"{{{PINNING_NS}}}") and pin is not None:
1621 continue
1622 new["conference"]["extensions"].append(el)
1624 if pin is not None:
1625 if existing_extensions:
1626 assert existing is not None
1627 existing_pin = (
1628 existing["conference"]["extensions"].get_plugin(
1629 "pinned", check=True
1630 )
1631 is not None
1632 )
1633 if existing_pin != pin:
1634 change = True
1635 new["conference"]["extensions"]["pinned"] = pin
1637 if notify is not None:
1638 new["conference"]["extensions"].enable("notify")
1639 if existing_extensions:
1640 assert existing is not None
1641 existing_notify = existing["conference"]["extensions"].get_plugin(
1642 "notify", check=True
1643 )
1644 if existing_notify is None:
1645 change = True
1646 else:
1647 if existing_notify.get_config() != notify:
1648 change = True
1649 for el in existing_notify:
1650 new["conference"]["extensions"]["notify"].append(el)
1651 new["conference"]["extensions"]["notify"].configure(notify)
1653 if change:
1654 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid)
1655 iq["pubsub"]["publish"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS
1656 iq["pubsub"]["publish"].append(new)
1658 iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS
1660 update_success = False
1661 try:
1662 await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
1663 except PermissionError:
1664 warnings.warn(
1665 f"IQ privileges (XEP0356) not granted for {self.user_jid}, we cannot add bookmarks for the user"
1666 )
1667 except IqError as e:
1668 warnings.warn(
1669 f"Something went wrong while trying to set the bookmarks: {e}"
1670 )
1671 else:
1672 update_success = True
1673 if existing is None and not update_success and not invite:
1674 self.session.send_gateway_invite(
1675 self,
1676 reason="This group could not be added automatically for you, most "
1677 "likely because this gateway is not configured as a privileged entity. "
1678 "Contact your administrator.",
1679 )
1680 return
1681 else:
1682 self.log.debug("Bookmark does not need updating.")
1684 if invite:
1685 self.session.send_gateway_invite(
1686 self.jid,
1687 reason="The gateway is configured to send invitations for groups.",
1688 )
1690 async def remove_from_bookmarks(self) -> None:
1691 """Remove the MUC from the user's XMPP bookmarks (:xep:`0402`).
1693 Does not actually leave the room on the legacy network, nor apply any other
1694 side-effect.
1696 Requires that slidge has the IQ privileged set correctly on the XMPP server.
1697 """
1698 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid)
1699 iq["pubsub"]["retract"]["node"] = self.xmpp.plugin["xep_0402"].stanza.NS
1700 iq["pubsub"]["retract"]["notify"] = "true"
1701 iq["pubsub"]["retract"]["item"]["id"] = str(self.jid)
1702 try:
1703 await self.xmpp.plugin["xep_0356"].send_privileged_iq(iq)
1704 except PermissionError:
1705 warnings.warn(
1706 f"IQ privileges (XEP0356) not granted for {self.user_jid}, "
1707 "we cannot remove bookmarks for the user"
1708 )
1709 except IqError as e:
1710 warnings.warn(
1711 f"Something went wrong while trying to remove a bookmark: {e}"
1712 )
1714 async def on_avatar(self, data: bytes | None, mime: str | None) -> str | None:
1715 """
1716 Called when the user tries to set the avatar of the room from an XMPP
1717 client.
1719 If the set avatar operation is completed, should return a legacy image
1720 unique identifier. In this case the MUC avatar will be immediately
1721 updated on the XMPP side.
1723 If data is not None and this method returns None, then we assume that
1724 self.set_avatar() will be called elsewhere, eg triggered by a legacy
1725 room update event.
1727 :param data: image data or None if the user meant to remove the avatar
1728 :param mime: the mime type of the image. Since this is provided by
1729 the XMPP client, there is no guarantee that this is valid or
1730 correct.
1731 :return: A unique avatar identifier, which will trigger
1732 :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if
1733 :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else.
1734 """
1735 raise NotImplementedError
1737 async def on_set_config(
1738 self,
1739 name: str | None,
1740 description: str | None,
1741 ) -> None:
1742 """
1743 Triggered when the user requests changing the room configuration.
1744 Only title and description can be changed at the moment.
1746 The legacy module is responsible for updating :attr:`.title` and/or
1747 :attr:`LegacyMUC.description` of this instance.
1749 If :attr:`.HAS_DESCRIPTION` is set to False, description will always
1750 be ``None``.
1752 :param name: The new name of the room.
1753 :param description: The new description of the room.
1754 """
1755 raise NotImplementedError
1757 async def on_destroy_request(self, reason: str | None) -> None:
1758 """
1759 Triggered when the user requests room destruction.
1761 :param reason: Optionally, a reason for the destruction
1762 """
1763 raise NotImplementedError
1765 async def parse_mentions(
1766 self, text: str | None
1767 ) -> tuple[Mention[LegacyParticipantType], ...]:
1768 if not text:
1769 return ()
1770 with self.xmpp.store.session() as orm:
1771 await self.__fill_participants()
1772 orm.add(self.stored)
1773 participants = {
1774 p.nickname: p for p in self.stored.participants if len(p.nickname) > 1
1775 }
1777 if len(participants) == 0:
1778 return ()
1780 result = []
1781 for match in re.finditer(
1782 "|".join(
1783 sorted(
1784 [re.escape(nick) for nick in participants],
1785 key=lambda nick: len(nick),
1786 reverse=True,
1787 )
1788 ),
1789 text,
1790 ):
1791 span = match.span()
1792 nick = match.group()
1793 if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION:
1794 continue
1795 if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION:
1796 participant = self.participant_from_store(stored=participants[nick])
1797 result.append(
1798 Mention(participant=participant, start=span[0], end=span[1])
1799 )
1800 return tuple(result)
1802 async def on_set_subject(self, subject: str) -> None:
1803 """
1804 Triggered when the user requests changing the room subject.
1806 The legacy module is responsible for updating :attr:`.subject` of this
1807 instance.
1809 :param subject: The new subject for this room.
1810 """
1811 raise NotImplementedError
1813 async def on_set_thread_subject(self, thread: str, subject: str) -> None:
1814 """
1815 Triggered when the user requests changing the subject of a specific thread.
1817 :param thread: Legacy identifier of the thread
1818 :param subject: The new subject for this thread.
1819 """
1820 raise NotImplementedError
1822 async def on_moderate(self, legacy_msg_id: str, reason: str | None) -> None:
1823 """
1824 Triggered when the user attempts to retract a message that was sent in
1825 a MUC using :xep:`0425`.
1827 If retraction is not possible, this should raise the appropriate
1828 XMPPError with a human-readable message.
1830 NB: the legacy module is responsible for calling
1831 :func:`LegacyParticipant.moderate` when this is successful, because
1832 slidge will acknowledge the moderation IQ, but will not send the
1833 moderation message from the MUC automatically.
1835 :param legacy_msg_id: The legacy ID of the message to be retracted
1836 :param reason: Optionally, a reason for the moderation, given by the
1837 user-moderator.
1838 """
1839 raise NotImplementedError
1841 async def on_leave(self) -> None:
1842 """
1843 Triggered when the user leaves a group via the dedicated slidge command
1844 or the :xep:`0077` ``<remove />`` mechanism.
1846 This should be interpreted as definitely leaving the group.
1847 """
1848 raise NotImplementedError
1850 @property
1851 def participants_filled(self) -> bool:
1852 # We don't store anything about participants before fill_participants()
1853 # has been called…
1854 if type(self).fill_participants is LegacyMUC.fill_participants:
1855 # …except if concrete MUC implementations do not override the
1856 # default no-op implementation.
1857 return True
1858 try:
1859 return self.stored.participants_filled
1860 except DetachedInstanceError:
1861 with self.orm(expire_on_commit=False) as orm:
1862 orm.add(self.stored)
1863 with orm.no_autoflush:
1864 orm.refresh(self.stored, ["participants_filled"])
1865 return self.stored.participants_filled
1867 def get_archived_messages(
1868 self, msg_id: str | list[str]
1869 ) -> Iterator[ArchivedMessage]:
1870 """
1871 Query the slidge archive for messages sent in this group
1873 :param msg_id: Message ID of the message in question. Can be either a legacy ID
1874 or an XMPP ID.
1875 :return: Iterator over messages. A single legacy ID can map to several messages,
1876 because of multi-attachment messages.
1877 """
1878 with self.xmpp.store.session() as orm:
1879 yield from self.xmpp.store.mam.get_messages(
1880 orm,
1881 self.stored.id,
1882 ids=[str(msg_id)] if isinstance(msg_id, str) else msg_id,
1883 )
1886def set_origin_id(msg: Message, origin_id: str) -> None:
1887 sub = ET.Element("{urn:xmpp:sid:0}origin-id")
1888 sub.attrib["id"] = origin_id
1889 msg.xml.append(sub)
1892def int_or_none(x: str) -> int | None:
1893 try:
1894 return int(x)
1895 except ValueError:
1896 return None
1899def equals_zero(x: int | None) -> bool:
1900 if x is None:
1901 return False
1902 else:
1903 return x == 0
1906def str_to_datetime_or_none(date: str | None) -> datetime | None:
1907 if date is None:
1908 return None
1909 try:
1910 return str_to_datetime(date)
1911 except ValueError:
1912 return None
1915def bookmarks_form() -> Form:
1916 form = Form()
1917 form["type"] = "submit"
1918 form.add_field(
1919 "FORM_TYPE",
1920 value="http://jabber.org/protocol/pubsub#publish-options",
1921 ftype="hidden",
1922 )
1923 form.add_field("pubsub#persist_items", value="1")
1924 form.add_field("pubsub#max_items", value="max")
1925 form.add_field("pubsub#send_last_published_item", value="never")
1926 form.add_field("pubsub#access_model", value="whitelist")
1927 return form
1930LegacyMUC.participant_cls = LegacyParticipant # type:ignore[misc]
1932_BOOKMARKS_OPTIONS = bookmarks_form()
1933_WHITESPACE_OR_PUNCTUATION = string.whitespace + "!\"'(),.:;?@_"
1935log = logging.getLogger(__name__)