Coverage for slidge/room/room.py: 91%
668 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-10 12:26 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-10 12:26 +0000
1from __future__ import annotations
3import hashlib
4import json
5import logging
6import re
7import string
8import uuid
9from asyncio import Lock
10from collections.abc import AsyncIterator, Iterable, Iterator
11from contextlib import asynccontextmanager
12from copy import copy
13from datetime import UTC, datetime, timedelta
14from typing import (
15 TYPE_CHECKING,
16 Any,
17 ClassVar,
18 Literal,
19 Self,
20)
22import sqlalchemy as sa
23from slixmpp import JID, Iq, Message, Presence
24from slixmpp.exceptions import XMPPError
25from slixmpp.plugins.xep_0004.stanza.form import Form
26from slixmpp.plugins.xep_0045.stanza import MUCUserItem
27from slixmpp.plugins.xep_0082 import parse as str_to_datetime
28from slixmpp.plugins.xep_0492.stanza import WhenLiteral
29from slixmpp.xmlstream import ET
30from sqlalchemy.orm import Session as OrmSession
31from typing_extensions import TypedDict
33from ..contact.contact import LegacyContact
34from ..core.mixins.avatar import ImageMixin
35from ..core.mixins.disco import ChatterDiscoMixin
36from ..core.mixins.recipient import RecipientMixin
37from ..db.models import ArchivedMessage, Participant, Room, Space
38from ..space import LegacySpace
39from ..util.types import (
40 AnyParticipant,
41 ExtraAttrMapping,
42 HoleBound,
43 Mention,
44 MucAffiliation,
45 RoomMessage,
46 RoomSticker,
47 RoomType,
48)
49from ..util.util import derive_wired_class, timeit, uniquify_replace
50from .archive import MessageArchive
51from .participant import LegacyParticipant
52from .participants import RoomRoster, get_resource
54if TYPE_CHECKING:
55 from ..command.base import RoomCommand
56 from ..core.session import BaseSession
57 from ..db.avatar import CachedAvatar
58 from .bookmarks import LegacyBookmarks
60ADMIN_NS = "http://jabber.org/protocol/muc#admin"
63type SubjectSetterType = "str | LegacyContact | AnyParticipant | None"
66class _RoomProps(TypedDict, total=False):
67 name: str | None
68 n_participants: int | None
69 subject_date: datetime | None
70 space_legacy_id: str | None
71 type: RoomType
72 pinned: bool | None
73 notify_settings: WhenLiteral | None
74 subject_setter: SubjectSetterType | None
75 user_nick: str | None
78class LegacyRoom[
79 LegacyParticipantT: AnyParticipant = Any,
80 T: ExtraAttrMapping = Any,
81](
82 ImageMixin[T, _RoomProps],
83 ChatterDiscoMixin,
84 RecipientMixin,
85):
86 """
87 A room, a.k.a. a Multi-User Chat.
89 Room instances are obtained by calling :py:meth:`slidge.group.rooms.LegacyBookmarks`
90 on the user's :py:class:`slidge.core.session.BaseSession`.
91 """
93 max_history_fetch = 100
95 is_group: Literal[True] = True
97 DISCO_TYPE = "text"
98 DISCO_CATEGORY = "conference"
100 STABLE_ARCHIVE = False
101 """
102 Because legacy events like reactions, editions, etc. don't all map to a stanza
103 with a proper legacy ID, slidge usually cannot guarantee the stability of the archive
104 across restarts.
106 Set this to True if you know what you're doing, but realistically, this can't
107 be set to True until archive is permanently stored on disk by slidge.
109 This is just a flag on archive responses that most clients ignore anyway.
110 """
112 """
113 Set this to true if the fill_participants() / fill_participants() design does not
114 fit the legacy API, ie, no lazy loading of the participant list and history.
115 """
117 HAS_DESCRIPTION = True
118 """
119 Set this to false if the legacy network does not allow setting a description
120 for the group. In this case the description field will not be present in the
121 room configuration form.
122 """
124 HAS_SUBJECT = True
125 """
126 Set this to false if the legacy network does not allow setting a subject
127 (sometimes also called topic) for the group. In this case, as a subject is
128 recommended by :xep:`0045` ("SHALL"), the description (or the group name as
129 ultimate fallback) will be used as the room subject.
130 By setting this to false, an error will be returned when the :term:`User`
131 tries to set the room subject.
132 """
134 archive: MessageArchive
136 _stored: Room
138 _commands: ClassVar[dict[str, type[RoomCommand[Any]]]] = {}
139 _commands_chat: ClassVar[dict[str, type[RoomCommand[Any]]]] = {}
141 _participant_cls: type[LegacyParticipantT]
142 """
143 The concrete :class:`.LegacyParticipant` subclass this Room produces.
145 Derived automatically from the generic parameter, e.g.,
146 ``class Room(LegacyRoom[Participant])`` produces ``Participant`` instances.
147 """
149 is_participant: Literal[False] = False
151 extra_attributes: T
153 _session: BaseSession[Any, LegacyBookmarks[Self]]
155 def __init_subclass__(cls, **kwargs: object) -> None:
156 super().__init_subclass__(**kwargs)
157 derive_wired_class(cls, LegacyRoom, "_participant_cls")
159 def __init__(
160 self, session: BaseSession[Any, LegacyBookmarks[Self]], stored: Room
161 ) -> None:
162 self._session = session
163 self.xmpp = session.xmpp
164 self._stored = stored
165 self._set_logger()
166 self.participants = RoomRoster[LegacyParticipantT](self)
167 super().__init__()
169 self.archive = MessageArchive(stored, self.xmpp.store)
171 async def on_message(self, message: RoomMessage[LegacyParticipantT]) -> str | None:
172 """
173 Triggered when the user sends a message to this :term:`Room`.
175 :return: A message ID of that can be used later to further reference
176 this message (reactions, read marks, etc.).
177 """
178 raise NotImplementedError
180 async def on_sticker(self, sticker: RoomSticker) -> str | None:
181 """
182 Triggered when the user sends a sticker to this :term:`Room`.
184 :param sticker: The sticker sent by the user.
186 :return: A message ID of that can be used later to further reference
187 this message (reactions, read marks, etc.).
188 """
189 raise NotImplementedError
191 def pop_unread_xmpp_ids_up_to(self, horizon_xmpp_id: str) -> list[str]:
192 """
193 Return XMPP msg ids sent in this group up to a given XMPP msg id.
195 Plugins have no reason to use this, but it is used by slidge core
196 for legacy networks that need to mark *all* messages as read (most XMPP
197 clients only send a read marker for the latest message).
199 This has side effects: all messages up to the horizon XMPP id will be marked
200 as read in the DB. If the horizon XMPP id is not found, all messages of this
201 Room will be marked as read.
203 :param horizon_xmpp_id: The latest message
204 :return: A list of XMPP ids if horizon_xmpp_id was not found
205 """
206 with self.xmpp.store.session() as orm:
207 assert self._stored.id is not None
208 ids = self.xmpp.store.mam.pop_unread_up_to(
209 orm, self._stored.id, horizon_xmpp_id
210 )
211 orm.commit()
212 return ids
214 @property
215 def jid(self) -> JID:
216 return self._stored.jid
218 @property
219 def legacy_id(self) -> str:
220 return self._stored.legacy_id
222 def __stored_space(self) -> Space | None:
223 self._stored.ensure_relation("space", self.xmpp.store.session)
224 return self._stored.space
226 @property
227 def space(self) -> LegacySpace | None:
228 stored = self.__stored_space()
229 return None if stored is None else self._session.spaces._from_store(stored)
231 @space.setter
232 def space(self, space: LegacySpace | None) -> None:
233 self._stored.ensure_relation("space", self.xmpp.store.session)
234 old = self._stored.space
236 new = space._stored if space else None
237 new_id = space._stored.id if space else None
239 self._update_stored_attribute(space_id=new_id)
240 self._stored._set_committed_value("space", new)
241 self._stored._set_committed_value("space_id", new_id)
243 if not self._session.rooms.ready.done():
244 return
246 if old is not None:
247 old.ensure_relation("avatar", self._session.xmpp.store.session)
248 old.ensure_relation("banner", self._session.xmpp.store.session)
249 old.load_rooms(self._session.xmpp.store.session)
250 self.xmpp.pubsub.broadcast_space(
251 self._session,
252 old,
253 self._session.spaces.legacy_id_to_node(old.legacy_id),
254 [str(self.jid)],
255 )
256 if new is not None:
257 new.ensure_relation("avatar", self._session.xmpp.store.session)
258 new.ensure_relation("banner", self._session.xmpp.store.session)
259 new.load_rooms(self._session.xmpp.store.session)
260 self.xmpp.pubsub.broadcast_space(
261 self._session,
262 new,
263 self._session.spaces.legacy_id_to_node(new.legacy_id),
264 [str(self.jid)],
265 )
267 def __orm(
268 self,
269 **kwargs: Any, # noqa:ANN401
270 ) -> OrmSession:
271 return self.xmpp.store.session(**kwargs)
273 @property
274 def type(self) -> RoomType:
275 return self._stored.type
277 @type.setter
278 def type(self, type_: RoomType) -> None:
279 self._update_stored_attribute(type=type_)
280 self._session.rooms.schedule_pep_add(self, "type change")
282 @property
283 def n_participants(self) -> int | None:
284 self._stored.ensure_loaded("disco", self.xmpp.store.session)
285 return self._stored.n_participants
287 @n_participants.setter
288 def n_participants(self, n_participants: int | None) -> None:
289 self._update_stored_attribute(n_participants=n_participants)
291 def _set_logger(self) -> None:
292 self.log = logging.getLogger(f"{self.user_jid}:muc:{self}")
294 def __repr__(self) -> str:
295 return f"<Room #{self._stored.id} '{self.name}' ({self._stored.legacy_id} - {self.jid.user})'>"
297 @property
298 def subject_date(self) -> datetime | None:
299 self._stored.ensure_loaded("subject", self.xmpp.store.session)
300 if self._stored.subject_date is None:
301 return None
302 return self._stored.subject_date.replace(tzinfo=UTC)
304 @subject_date.setter
305 def subject_date(self, when: datetime | None) -> None:
306 self._update_stored_attribute(subject_date=when)
308 def __send_configuration_change(self, codes: tuple[int, ...]) -> None:
309 part = self.get_system_participant()
310 part.send_configuration_change(codes)
312 @property
313 def user_nick(self) -> str:
314 return (
315 self._stored.user_nick
316 or self._session.rooms.user_nick
317 or self.user_jid.node
318 )
320 @user_nick.setter
321 def user_nick(self, nick: str) -> None:
322 self._update_stored_attribute(user_nick=nick)
324 def add_user_resource(self, resource: str) -> None:
325 stored_set = self.get_user_resources()
326 if resource in stored_set:
327 return
328 stored_set.add(resource)
329 self._update_stored_attribute(
330 user_resources=(json.dumps(list(stored_set)) if stored_set else None)
331 )
333 def get_user_resources(self) -> set[str]:
334 stored_str = self._stored.user_resources
335 if stored_str is None:
336 return set()
337 return set(json.loads(stored_str))
339 def remove_user_resource(self, resource: str) -> None:
340 stored_set = self.get_user_resources()
341 if resource not in stored_set:
342 return
343 stored_set.remove(resource)
344 self._update_stored_attribute(
345 user_resources=(json.dumps(list(stored_set)) if stored_set else None)
346 )
348 @asynccontextmanager
349 async def lock(self, id_: str) -> AsyncIterator[None]:
350 async with self._session.lock((self.legacy_id, id_)):
351 yield
353 def get_lock(self, id_: str) -> Lock | None:
354 return self._session.get_lock((self.legacy_id, id_))
356 async def get_participants(
357 self, affiliation: MucAffiliation | None = None
358 ) -> AsyncIterator[LegacyParticipantT]:
359 await self.participants.ensure_filled()
360 for p in self.participants.__iter__(affiliation):
361 yield p
363 async def __fill_history(self) -> None:
364 async with self.lock("fill history"):
365 with self.xmpp.store.session(expire_on_commit=False) as orm:
366 self._stored._set_committed_value(
367 "history_filled",
368 orm.scalar(
369 sa.select(
370 Room.history_filled,
371 ).where(Room.id == self._stored.id)
372 ),
373 )
374 if self._stored.history_filled:
375 self.log.debug("History has already been fetched.")
376 return
377 log.debug("Fetching history for %s", self)
378 try:
379 await self.backfill(*self.archive.get_hole_bounds())
380 except NotImplementedError:
381 return
382 except Exception as e:
383 self.log.exception("Could not backfill", exc_info=e)
384 finally:
385 self._update_stored_attribute(history_filled=True)
387 def _get_disco_name(self) -> str | None:
388 return self.name
390 @property
391 def name(self) -> str | None:
392 return self._stored.name
394 @name.setter
395 def name(self, n: str | None) -> None:
396 if not self._update_stored_attribute(name=n):
397 return
398 self._set_logger()
399 self.__send_configuration_change((104,))
401 @property
402 def description(self) -> str:
403 self._stored.ensure_loaded("disco", self.xmpp.store.session)
404 return self._stored.description or ""
406 @description.setter
407 def description(self, d: str | None) -> None:
408 d = d or ""
410 if not self._update_stored_attribute(description=d):
411 return
412 self.__send_configuration_change((104,))
414 def on_presence_unavailable(self, p: Presence) -> None:
415 pto = p.get_to()
416 if pto.bare != self.jid.bare:
417 return
419 pfrom = p.get_from()
420 if pfrom.bare != self.user_jid.bare:
421 return
422 if (resource := pfrom.resource) in self.get_user_resources():
423 if pto.resource != self.user_nick:
424 self.log.debug(
425 "Received 'leave group' request but with wrong nickname. %s", p
426 )
427 self.remove_user_resource(resource)
428 else:
429 self.log.debug(
430 "Received 'leave group' request but resource was not listed. %s", p
431 )
433 async def backfill(
434 self,
435 after: HoleBound | None = None,
436 before: HoleBound | None = None,
437 ) -> None:
438 """
439 Override this if the legacy network provide server-side group archives.
441 In it, send history messages using ``self.participants.get_or_create(...).send_xxxx``.
442 This is only called once per slidge run for a given group.
444 :param after: Fetch messages after this one.
445 If ``None``, slidge's local archive was empty before start-up,
446 ie, no history was ever fetched for this room since the user registered.
447 It's up to gateway implementations to decide how far to fetch messages before
448 the user registered.
449 If not ``None``, slidge has some messages in this archive, and
450 the gateway shall try to fetch history up to (and excluding) this message
451 to avoid "holes" in the history of this group.
452 :param before: Fetch messages before this one.
453 If ``None``, the gateway shall fetch all messages up to the most recent one.
454 If not ``None``, slidge has already archived some live messages
455 it received during its lifetime, and there is no need to query the legacy
456 network for any message after (and including) this one.
457 """
458 raise NotImplementedError
460 async def fill_participants(self) -> AsyncIterator[LegacyParticipantT]:
461 """
462 This method should yield the list of all members of this room.
464 It will only be called once per-room, only when actually needed by
465 an XMPP action (typically joining the room).
466 """
467 return
468 yield
470 @property
471 def subject(self) -> str:
472 self._stored.ensure_loaded("subject", self.xmpp.store.session)
473 return self._stored.subject or ""
475 @subject.setter
476 def subject(self, s: str) -> None:
477 if not self._update_stored_attribute(subject=s):
478 return
479 self.__get_subject_setter_participant().set_room_subject(
480 s, None, self.subject_date, False
481 )
483 @property
484 def is_anonymous(self) -> bool:
485 return self.type == RoomType.CHANNEL
487 @property
488 def subject_setter(self) -> str | None:
489 self._stored.ensure_loaded("subject", self.xmpp.store.session)
490 return self._stored.subject_setter
492 @subject_setter.setter
493 def subject_setter(self, subject_setter: SubjectSetterType) -> None:
494 if isinstance(subject_setter, LegacyContact):
495 subject_setter = subject_setter.name
496 elif isinstance(subject_setter, LegacyParticipant):
497 subject_setter = subject_setter.nickname
499 assert isinstance(subject_setter, str | None)
500 self._update_stored_attribute(subject_setter=subject_setter)
502 def __get_subject_setter_participant(self) -> AnyParticipant:
503 if self.subject_setter:
504 nick = self.subject_setter or "subject-setter"
505 return self._participant_cls(
506 self,
507 Participant(
508 nickname=nick,
509 resource=get_resource(nick),
510 ),
511 )
512 else:
513 return self.get_system_participant()
515 @property
516 def notify_settings(self) -> WhenLiteral | None:
517 self._stored.ensure_loaded("bookmarks", self.__orm)
518 return self._stored.notify_settings
520 @notify_settings.setter
521 def notify_settings(self, when: WhenLiteral | None) -> None:
522 if self._update_stored_attribute(notify_settings=when):
523 self._session.rooms.schedule_pep_add(self, "notify setting change")
525 @property
526 def pinned(self) -> bool | None:
527 self._stored.ensure_loaded("bookmarks", self.__orm)
528 return self._stored.pinned
530 @pinned.setter
531 def pinned(self, pinned: bool | None) -> None:
532 if self._update_stored_attribute(pinned=pinned):
533 self._session.rooms.schedule_pep_add(self, "pinned change")
535 def features(self) -> list[str]:
536 features = [
537 "http://jabber.org/protocol/muc",
538 "http://jabber.org/protocol/muc#stable_id",
539 "http://jabber.org/protocol/muc#self-ping-optimization",
540 "urn:xmpp:mam:2",
541 "urn:xmpp:mam:2#extended",
542 "urn:xmpp:sid:0",
543 "muc_persistent",
544 "vcard-temp",
545 "urn:xmpp:ping",
546 "urn:xmpp:occupant-id:0",
547 "jabber:iq:register",
548 "http://jabber.org/protocol/commands",
549 "urn:xmpp:muc:affiliations:1",
550 self.xmpp.plugin["xep_0425"].stanza.NS,
551 ]
552 if self.type == RoomType.GROUP:
553 features.extend(["muc_membersonly", "muc_nonanonymous", "muc_hidden"])
554 elif self.type == RoomType.CHANNEL:
555 features.extend(["muc_open", "muc_semianonymous", "muc_public"])
556 elif self.type == RoomType.CHANNEL_NON_ANONYMOUS:
557 features.extend(["muc_open", "muc_nonanonymous", "muc_public"])
559 if self._stored.space_id is not None:
560 features.append("urn:xmpp:spaces:0")
561 return features
563 async def extended_features(self) -> list[Form]:
564 is_group = self.type == RoomType.GROUP
566 form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
568 form.add_field(
569 "FORM_TYPE", "hidden", value="http://jabber.org/protocol/muc#roominfo"
570 )
571 form.add_field("muc#roomconfig_persistentroom", "boolean", value=True)
572 form.add_field("muc#roomconfig_changesubject", "boolean", value=False)
573 form.add_field("muc#maxhistoryfetch", value=str(self.max_history_fetch))
574 form.add_field("muc#roominfo_subjectmod", "boolean", value=False)
576 self._stored.ensure_loaded("disco", self.xmpp.store.session)
577 if self.participants_filled:
578 with self.xmpp.store.session() as orm:
579 n = orm.scalar(
580 sa.select(sa.func.count(Participant.id)).filter_by(
581 room_id=self._stored.id
582 )
583 )
584 else:
585 n = self._stored.n_participants
587 if n is not None:
588 form.add_field("muc#roominfo_occupants", value=str(n))
590 if d := self._stored.description:
591 form.add_field("muc#roominfo_description", value=d)
593 if s := self.subject:
594 form.add_field("muc#roominfo_subject", value=s)
596 if name := self.name:
597 form.add_field("muc#roomconfig_roomname", value=name)
599 if self._set_avatar_task is not None:
600 await self._set_avatar_task
601 avatar = self.get_avatar()
602 if avatar and (h := avatar.id):
603 form.add_field(
604 "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1", value=h
605 )
606 form.add_field("muc#roominfo_avatarhash", "text-multi", value=[h])
607 if avatar.url:
608 form.add_field("{http://slidge.im}/avatar#url", value=avatar.url)
610 form.add_field("muc#roomconfig_membersonly", "boolean", value=is_group)
611 form.add_field(
612 "muc#roomconfig_whois",
613 "list-single",
614 value="moderators" if self.is_anonymous else "anyone",
615 )
616 form.add_field("muc#roomconfig_publicroom", "boolean", value=not is_group)
617 form.add_field("muc#roomconfig_allowpm", "boolean", value=False)
619 r = [form]
621 if reaction_form := await self.restricted_emoji_extended_feature():
622 r.append(reaction_form)
624 if self._stored.space_id is not None:
625 stored = self.__stored_space()
626 assert stored is not None
627 node = self._session.spaces.legacy_id_to_node(stored.legacy_id)
628 iri = f"xmpp:{self.xmpp.boundjid.bare}?node={node}"
629 form.add_field("muc#roominfo_pubsub", value=iri)
630 space_form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
631 space_form.add_field("FORM_TYPE", "hidden", value="urn:xmpp:spaces:0")
632 space_form.add_field("parent", label="Space parent", value=iri)
634 return r
636 def user_full_jids(self) -> Iterable[JID]:
637 for r in self.get_user_resources():
638 j = JID(self.user_jid)
639 j.resource = r
640 yield j
642 @property
643 def user_muc_jid(self) -> JID:
644 return self.participants.get_user().jid
646 async def echo(
647 self, msg: Message, legacy_msg_id: str | None = None, replace: str | None = None
648 ) -> str:
649 msg.set_from(self.user_muc_jid)
650 if replace and not (legacy_msg_id or legacy_msg_id == replace):
651 msg["stanza_id"]["id"] = uniquify_replace(replace)
652 elif legacy_msg_id:
653 msg["stanza_id"]["id"] = legacy_msg_id
654 else:
655 msg["stanza_id"]["id"] = str(uuid.uuid4())
656 msg["stanza_id"]["by"] = self.jid
658 user_part = self.participants.get_user()
659 msg["occupant-id"]["id"] = user_part.occupant_id
661 self.archive.add(msg, user_part, legacy_msg_id=legacy_msg_id)
663 for user_full_jid in self.user_full_jids():
664 self.log.debug("Echoing to %s", user_full_jid)
665 msg = copy(msg)
666 msg.set_to(user_full_jid)
668 msg.send()
670 return msg["stanza_id"]["id"] # type:ignore[no-any-return]
672 def _post_image_update(
673 self, cached_avatar: CachedAvatar | None, *, attr: str = "avatar"
674 ) -> None:
675 self.__send_configuration_change((104,))
676 self._send_room_presence()
678 def _send_room_presence(self, user_full_jid: JID | None = None) -> None:
679 tos = self.user_full_jids() if user_full_jid is None else [user_full_jid]
680 for to in tos:
681 p = self.xmpp.make_presence(pfrom=self.jid, pto=to)
682 if (avatar := self.get_avatar()) and (h := avatar.id):
683 p["vcard_temp_update"]["photo"] = h
684 if avatar.http_metadata is not None:
685 metadata = self.xmpp.plugin["xep_0084"].stanza.MetaData()
686 metadata.append(avatar.http_metadata)
687 p.append(metadata)
688 else:
689 p["vcard_temp_update"]["photo"] = ""
690 p.send()
692 @timeit
693 async def join(self, join_presence: Presence) -> None:
694 user_full_jid = join_presence.get_from()
695 requested_nickname = join_presence.get_to().resource
696 client_resource = user_full_jid.resource
698 if client_resource in self.get_user_resources():
699 self.log.debug("Received join from a resource that is already joined.")
701 if not requested_nickname or not client_resource:
702 raise XMPPError("jid-malformed", by=self.jid)
704 self.add_user_resource(client_resource)
706 self.log.debug(
707 "Resource %s of %s wants to join room %s with nickname %s",
708 client_resource,
709 self.user_jid,
710 self.legacy_id,
711 requested_nickname,
712 )
714 await self.participants.ensure_filled(with_presence=True)
715 self._stored.load_participants(self.__orm, with_presence=True)
716 if "mav" in join_presence["muc_join"]:
717 mav_until = await self.__get_mav()
718 self.log.debug("client uses Room affiliation versioning")
719 if join_presence["muc_join"]["mav"]["since"] != mav_until:
720 self.log.debug(
721 "client mav: %s vs our mav: %s",
722 join_presence["muc_join"]["mav"]["since"],
723 mav_until,
724 )
725 await self.__send_mav(user_full_jid, mav_until)
726 else:
727 mav_until = None
729 self.log.debug("Sending presences for join of %s", client_resource)
731 self._stored.load_participants(self.xmpp.store.session, with_presence=True)
732 user_participant = None
734 for db_participant in self._stored.participants:
735 participant = self.participants._from_store(db_participant)
736 if participant.is_user:
737 user_participant = participant
738 continue
739 participant.send_initial_presence(full_jid=user_full_jid)
741 if user_participant is None:
742 user_participant = self.participants.get_user()
743 with self.xmpp.store.session() as orm:
744 orm.add(self._stored)
745 with orm.no_autoflush:
746 orm.refresh(self._stored, ["participants"])
747 if not user_participant.is_user:
748 self.log.warning("is_user flag not set on user_participant")
749 user_participant.is_user = True
750 user_participant.send_initial_presence(
751 user_full_jid,
752 presence_id=join_presence["id"],
753 nick_change=user_participant.jid.resource != requested_nickname,
754 mav_until=mav_until,
755 )
757 self.log.debug("Sending presences for join of %s: DONE", client_resource)
759 history_params = join_presence["muc_join"]["history"]
760 maxchars = int_or_none(history_params["maxchars"])
761 maxstanzas = int_or_none(history_params["maxstanzas"])
762 seconds = int_or_none(history_params["seconds"])
763 try:
764 since = self.xmpp.plugin["xep_0082"].parse(history_params["since"])
765 except ValueError:
766 since = None
767 if seconds is not None:
768 since = datetime.now(tz=UTC) - timedelta(seconds=seconds)
769 if equals_zero(maxchars) or equals_zero(maxstanzas):
770 log.debug(
771 "Joining client does not want any old-school Room history-on-join"
772 )
773 else:
774 self.log.debug("Old school history fill")
775 await self.__fill_history()
776 await self.__old_school_history(
777 user_full_jid,
778 maxchars=maxchars,
779 maxstanzas=maxstanzas,
780 since=since,
781 )
782 if self.HAS_SUBJECT:
783 subject = self.subject or ""
784 else:
785 subject = self.description or self.name or ""
786 self.__get_subject_setter_participant().set_room_subject(
787 subject,
788 user_full_jid,
789 self.subject_date,
790 update_muc=False,
791 )
792 if t := self._set_avatar_task:
793 await t
794 self._send_room_presence(user_full_jid)
796 async def __get_mav(self) -> str:
797 data = self.__get_mav_data()
798 return self.__compute_mav_ver(data)
800 def __get_mav_data(self) -> list[tuple[str, MucAffiliation]]:
801 data: list[tuple[str, MucAffiliation]] = []
803 self._stored.load_participants(self.__orm, with_presence=False)
804 for part in self._stored.participants:
805 if not (part.is_user or part.contact):
806 continue
807 if part.affiliation == "none":
808 continue
809 data.append((self.__get_part_mav_id(part), part.affiliation))
810 return data
812 def __get_part_mav_id(self, part: LegacyParticipantT | Participant) -> str:
813 return str(self.user_jid if part.is_user else part.contact.legacy_id) # type:ignore[union-attr] # ty:ignore[unresolved-attribute]
815 def __compute_mav_ver(self, data: list[tuple[Any, MucAffiliation]]) -> str:
816 self.log.debug("MAV data: %s", data)
817 affs = []
818 for id_, aff in data:
819 if aff == "none":
820 continue
821 affs.append(f"{id_}\0{aff}".encode())
822 affs.sort()
823 return hashlib.sha256(b"\0".join(affs)).hexdigest()
825 async def __send_mav(self, full_jid: JID, until: str) -> None:
826 self.log.debug("Sending MAV")
827 msg = self.xmpp.make_message(mto=full_jid, mfrom=self.jid)
828 msg["muc"]["mav"]["until"] = until
829 for part in self._stored.participants:
830 if not (part.is_user or part.contact):
831 continue
832 item = MUCUserItem()
833 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare # type:ignore[union-attr]
834 item["affiliation"] = part.affiliation
835 msg["muc"].append(item)
836 msg.send()
838 def send_affiliation_change(
839 self, part: LegacyParticipantT, was: MucAffiliation = "none"
840 ) -> None:
841 # internal use by slidge
842 if not self.participants_filled:
843 return
844 if self.get_lock("fill participants"):
845 return
846 if self.get_lock("fill history"):
847 return
848 if part.contact is None:
849 return
850 if part.is_system:
851 return
852 if was == part.affiliation:
853 return
854 system_part = self.get_system_participant()
855 msg = system_part._make_message(mtype="normal")
856 until = self.__get_mav_data()
857 since = [
858 (id_, was if id_ == self.__get_part_mav_id(part) else aff)
859 for id_, aff in until
860 ]
861 if part.affiliation == "none":
862 since.append((self.__get_part_mav_id(part), was))
863 msg["muc"]["mav"]["since"] = self.__compute_mav_ver(since)
864 msg["muc"]["mav"]["until"] = self.__compute_mav_ver(until)
865 item = MUCUserItem()
866 item["affiliation"] = part.affiliation
867 item["jid"] = self.user_jid if part.is_user else part.contact.jid.bare
868 msg["muc"].append(item)
869 system_part._send(msg)
871 def get_system_participant(self) -> LegacyParticipantT:
872 """
873 Get a pseudo-participant, representing the room itself
875 Can be useful for events that cannot be mapped to a participant,
876 e.g. anonymous moderation events, or announces from the legacy
877 service
878 :return:
879 """
880 return self._participant_cls(
881 self, Participant(occupant_id="room"), is_system=True
882 )
884 def remove_participant(
885 self,
886 p: LegacyParticipantT,
887 kick: bool = False,
888 ban: bool = False,
889 reason: str | None = None,
890 ) -> None:
891 """
892 Call this when a participant leaves the room
894 :param p: The participant
895 :param kick: Whether the participant left because they were kicked
896 :param ban: Whether the participant left because they were banned
897 :param reason: Optionally, a reason why the participant was removed.
898 """
899 self.log.debug("Removing participant: %s", p)
900 if kick and ban:
901 raise TypeError("Either kick or ban")
902 if kick:
903 codes = {307}
904 elif ban:
905 codes = {301}
906 else:
907 codes = None
908 was = p._stored.affiliation
909 p._stored.affiliation = "outcast" if ban else "none"
910 p._stored.role = "none"
911 presence = p._make_presence(ptype="unavailable", status_codes=codes)
912 with self.__orm() as orm:
913 self.xmpp.store.participants.delete(orm, p._stored.id)
914 orm.commit()
915 self._stored.delete_participant(p._stored)
916 self.send_affiliation_change(p, was)
917 if reason:
918 presence["muc"].set_item_attr("reason", reason)
919 p._send(presence, force=True)
921 def rename_participant(self, old_nickname: str, new_nickname: str) -> None:
922 part = self.participants.get_by_nick(old_nickname)
923 if part is None:
924 self.log.debug("Tried to rename a participant that we didn't know")
925 return
926 part.nickname = new_nickname
928 async def __old_school_history(
929 self,
930 full_jid: JID,
931 maxchars: int | None = None,
932 maxstanzas: int | None = None,
933 seconds: int | None = None,
934 since: datetime | None = None,
935 ) -> None:
936 """
937 Old-style history join (internal slidge use)
939 :param full_jid:
940 :param maxchars:
941 :param maxstanzas:
942 :param seconds:
943 :param since:
944 :return:
945 """
946 if since is None:
947 if seconds is None:
948 start_date = datetime.now(tz=UTC) - timedelta(days=1)
949 else:
950 start_date = datetime.now(tz=UTC) - timedelta(seconds=seconds)
951 else:
952 start_date = since or datetime.now(tz=UTC) - timedelta(days=1)
954 for h_msg in self.archive.get_all(
955 start_date=start_date, end_date=None, last_page_n=maxstanzas
956 ):
957 msg = h_msg.stanza("jabber:component:accept", user_jid=self.user_jid.bare)
958 msg["delay"]["stamp"] = h_msg.timestamp.replace(tzinfo=UTC)
959 msg["delay"]["from"] = self.jid
960 msg.set_to(full_jid)
961 self.xmpp.send(msg, False)
963 async def send_mam(self, iq: Iq) -> None:
964 await self.__fill_history()
966 form_values = iq["mam"]["form"].get_values()
968 start_date = str_to_datetime_or_none(form_values.get("start"))
969 end_date = str_to_datetime_or_none(form_values.get("end"))
971 after_id = form_values.get("after-id")
972 before_id = form_values.get("before-id")
974 ids = form_values.get("ids") or ()
976 if max_str := iq["mam"]["rsm"]["max"]:
977 try:
978 max_results = int(max_str)
979 except ValueError:
980 max_results = None
981 else:
982 max_results = None
984 after_id_rsm = iq["mam"]["rsm"]["after"]
985 after_id = after_id_rsm or after_id
987 before_rsm = iq["mam"]["rsm"]["before"]
988 if before_rsm is not None and max_results is not None:
989 last_page_n = max_results
990 # - before_rsm is True means the empty element <before />, which means
991 # "last page in chronological order", cf https://xmpp.org/extensions/xep-0059.html#backwards
992 # - before_rsm == "an ID" means <before>an ID</before>
993 if before_rsm is not True:
994 before_id = before_rsm
995 else:
996 last_page_n = None
998 first = None
999 last = None
1000 count = 0
1002 sender = form_values.get("with")
1003 sender_resource = JID(sender).resource if sender else None
1005 it = self.archive.get_all(
1006 start_date,
1007 end_date,
1008 before_id,
1009 after_id,
1010 ids,
1011 last_page_n,
1012 sender_resource,
1013 bool(iq["mam"]["flip_page"]),
1014 )
1016 for history_msg in it:
1017 last = xmpp_id = history_msg.stanza_id
1018 if first is None:
1019 first = xmpp_id
1021 wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from())
1022 wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"]
1023 wrapper_msg["mam_result"]["id"] = xmpp_id
1024 wrapper_msg["mam_result"].append(
1025 history_msg.forwarded(user_jid=self.user_jid.bare)
1026 )
1028 wrapper_msg.send()
1029 count += 1
1031 if max_results and count == max_results:
1032 break
1034 if max_results:
1035 try:
1036 next(it)
1037 except StopIteration:
1038 complete = True
1039 else:
1040 complete = False
1041 else:
1042 complete = True
1044 reply = iq.reply()
1045 if not self.STABLE_ARCHIVE:
1046 reply["mam_fin"]["stable"] = "false"
1047 if complete:
1048 reply["mam_fin"]["complete"] = "true"
1049 reply["mam_fin"]["rsm"]["first"] = first
1050 reply["mam_fin"]["rsm"]["last"] = last
1051 reply["mam_fin"]["rsm"]["count"] = str(count)
1052 reply.send()
1054 async def send_mam_metadata(self, iq: Iq) -> None:
1055 await self.__fill_history()
1056 await self.archive.send_metadata(iq)
1058 async def kick_resource(self, r: str) -> None:
1059 """
1060 Kick a XMPP client of the user. (slidge internal use)
1062 :param r: The resource to kick
1063 """
1064 pto = JID(self.user_jid)
1065 pto.resource = r
1066 p = self.xmpp.make_presence(pfrom=(self.participants.get_user()).jid, pto=pto)
1067 p["type"] = "unavailable"
1068 p["muc"]["affiliation"] = "none"
1069 p["muc"]["role"] = "none"
1070 p["muc"]["status_codes"] = {110, 333}
1071 p.send()
1073 def _on_delete(self) -> None:
1074 self._session.create_task(
1075 self._session.rooms._pep_del(self), f"Remove {self} from PEP bookmarks"
1076 )
1078 async def on_avatar(self, data: bytes | None, mime: str | None) -> str | None:
1079 """
1080 Called when the user tries to set the avatar of the room from an XMPP
1081 client.
1083 If the set avatar operation is completed, should return a legacy image
1084 unique identifier. In this case the Room avatar will be immediately
1085 updated on the XMPP side.
1087 If data is not None and this method returns None, then we assume that
1088 self.set_avatar() will be called elsewhere, eg triggered by a legacy
1089 room update event.
1091 :param data: image data or None if the user meant to remove the avatar
1092 :param mime: the mime type of the image. Since this is provided by
1093 the XMPP client, there is no guarantee that this is valid or
1094 correct.
1095 :return: A unique avatar identifier, which will trigger
1096 :py:meth:`slidge.group.room.LegacyRoom.set_avatar`. Alternatively, None, if
1097 :py:meth:`.LegacyRoom.set_avatar` is meant to be awaited somewhere else.
1098 """
1099 raise NotImplementedError
1101 async def on_set_config(
1102 self,
1103 name: str | None,
1104 description: str | None,
1105 ) -> None:
1106 """
1107 Triggered when the user requests changing the room configuration.
1108 Only title and description can be changed at the moment.
1110 The legacy module is responsible for updating :attr:`.title` and/or
1111 :attr:`LegacyRoom.description` of this instance.
1113 If :attr:`.HAS_DESCRIPTION` is set to False, description will always
1114 be ``None``.
1116 :param name: The new name of the room.
1117 :param description: The new description of the room.
1118 """
1119 raise NotImplementedError
1121 async def on_destroy_request(self, reason: str | None) -> None:
1122 """
1123 Triggered when the user requests room destruction.
1125 :param reason: Optionally, a reason for the destruction
1126 """
1127 raise NotImplementedError
1129 async def parse_mentions(
1130 self, text: str | None
1131 ) -> tuple[Mention[LegacyParticipantT], ...]:
1132 if not text:
1133 return ()
1134 await self.participants.ensure_filled()
1135 with self.xmpp.store.session() as orm:
1136 orm.add(self._stored)
1137 participants = {
1138 p.nickname: p for p in self._stored.participants if len(p.nickname) > 1
1139 }
1141 if len(participants) == 0:
1142 return ()
1144 result = []
1145 for match in re.finditer(
1146 "|".join(
1147 sorted(
1148 [re.escape(nick) for nick in participants],
1149 key=lambda nick: len(nick),
1150 reverse=True,
1151 )
1152 ),
1153 text,
1154 ):
1155 span = match.span()
1156 nick = match.group()
1157 if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION:
1158 continue
1159 if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION:
1160 participant = self.participants._from_store(
1161 stored=participants[nick]
1162 )
1163 result.append(
1164 Mention(participant=participant, start=span[0], end=span[1])
1165 )
1166 return tuple(result)
1168 async def on_set_subject(self, subject: str) -> None:
1169 """
1170 Triggered when the user requests changing the room subject.
1172 The legacy module is responsible for updating :attr:`.subject` of this
1173 instance.
1175 :param subject: The new subject for this room.
1176 """
1177 raise NotImplementedError
1179 async def on_set_thread_subject(self, thread: str, subject: str) -> None:
1180 """
1181 Triggered when the user requests changing the subject of a specific thread.
1183 :param thread: Legacy identifier of the thread
1184 :param subject: The new subject for this thread.
1185 """
1186 raise NotImplementedError
1188 async def on_moderate(self, legacy_msg_id: str, reason: str | None) -> None:
1189 """
1190 Triggered when the user attempts to retract a message that was sent in
1191 a Room using :xep:`0425`.
1193 If retraction is not possible, this should raise the appropriate
1194 XMPPError with a human-readable message.
1196 NB: the legacy module is responsible for calling
1197 :func:`LegacyParticipant.moderate` when this is successful, because
1198 slidge will acknowledge the moderation IQ, but will not send the
1199 moderation message from the Room automatically.
1201 :param legacy_msg_id: The legacy ID of the message to be retracted
1202 :param reason: Optionally, a reason for the moderation, given by the
1203 user-moderator.
1204 """
1205 raise NotImplementedError
1207 async def on_leave(self) -> None:
1208 """
1209 Triggered when the user leaves a group via the dedicated slidge command
1210 or the :xep:`0077` ``<remove />`` mechanism.
1212 This should be interpreted as definitely leaving the group.
1213 """
1214 raise NotImplementedError
1216 @property
1217 def participants_filled(self) -> bool:
1218 return self._stored.participants_filled
1220 def get_archived_messages(
1221 self, msg_id: str | list[str]
1222 ) -> Iterator[ArchivedMessage]:
1223 """
1224 Query the slidge archive for messages sent in this group
1226 :param msg_id: Message ID of the message in question. Can be either a legacy ID
1227 or an XMPP ID.
1228 :return: Iterator over messages. A single legacy ID can map to several messages,
1229 because of multi-attachment messages.
1230 """
1231 with self.xmpp.store.session() as orm:
1232 yield from self.xmpp.store.mam.get_messages(
1233 orm,
1234 self._stored.id,
1235 ids=[str(msg_id)] if isinstance(msg_id, str) else msg_id,
1236 )
1239def set_origin_id(msg: Message, origin_id: str) -> None:
1240 sub = ET.Element("{urn:xmpp:sid:0}origin-id")
1241 sub.attrib["id"] = origin_id
1242 msg.xml.append(sub)
1245def int_or_none(x: str) -> int | None:
1246 try:
1247 return int(x)
1248 except ValueError:
1249 return None
1252def equals_zero(x: int | None) -> bool:
1253 if x is None:
1254 return False
1255 else:
1256 return x == 0
1259def str_to_datetime_or_none(date: str | None) -> datetime | None:
1260 if date is None:
1261 return None
1262 try:
1263 return str_to_datetime(date)
1264 except ValueError:
1265 return None
1268LegacyRoom._participant_cls = LegacyParticipant # type:ignore[misc]
1270_WHITESPACE_OR_PUNCTUATION = string.whitespace + "!\"'(),.:;?@_"
1272log = logging.getLogger(__name__)