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