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