Coverage for slidge / group / room.py: 88%
796 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-03-13 22:59 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-03-13 22:59 +0000
1import json
2import logging
3import re
4import string
5import uuid
6import warnings
7from asyncio import Lock
8from collections.abc import AsyncIterator, Iterable, Iterator
9from contextlib import asynccontextmanager
10from copy import copy
11from datetime import UTC, datetime, timedelta
12from typing import (
13 TYPE_CHECKING,
14 Any,
15 Generic,
16 Literal,
17 Union,
18 overload,
19)
21import sqlalchemy as sa
22from slixmpp import JID, Iq, Message, Presence
23from slixmpp.exceptions import IqError, IqTimeout, XMPPError
24from slixmpp.plugins.xep_0004 import Form
25from slixmpp.plugins.xep_0060.stanza import Item
26from slixmpp.plugins.xep_0082 import parse as str_to_datetime
27from slixmpp.plugins.xep_0469.stanza import NS as PINNING_NS
28from slixmpp.plugins.xep_0492.stanza import NS as NOTIFY_NS
29from slixmpp.plugins.xep_0492.stanza import WhenLiteral
30from slixmpp.xmlstream import ET
31from sqlalchemy.exc import IntegrityError
32from sqlalchemy.orm import Session as OrmSession
34from ..contact.contact import LegacyContact
35from ..contact.roster import ContactIsUser
36from ..core.mixins.avatar import AvatarMixin
37from ..core.mixins.disco import ChatterDiscoMixin
38from ..core.mixins.recipient import ReactionRecipientMixin, ThreadRecipientMixin
39from ..db.models import Participant, Room
40from ..util.archive_msg import HistoryMessage
41from ..util.jid_escaping import unescape_node
42from ..util.types import (
43 HoleBound,
44 LegacyGroupIdType,
45 LegacyMessageType,
46 LegacyParticipantType,
47 LegacyThreadType,
48 LegacyUserIdType,
49 Mention,
50 MucAffiliation,
51 MucType,
52)
53from ..util.util import SubclassableOnce, deprecated, timeit
54from .archive import MessageArchive
55from .participant import LegacyParticipant, escape_nickname
57if TYPE_CHECKING:
58 from ..core.session import BaseSession
59 from ..db.avatar import CachedAvatar
61ADMIN_NS = "http://jabber.org/protocol/muc#admin"
63SubjectSetterType = Union[str, None, "LegacyContact", "LegacyParticipant"]
66class LegacyMUC(
67 Generic[
68 LegacyGroupIdType, LegacyMessageType, LegacyParticipantType, LegacyUserIdType
69 ],
70 AvatarMixin,
71 ChatterDiscoMixin,
72 ReactionRecipientMixin,
73 ThreadRecipientMixin,
74 SubclassableOnce,
75):
76 """
77 A room, a.k.a. a Multi-User Chat.
79 MUC instances are obtained by calling :py:meth:`slidge.group.bookmarks.LegacyBookmarks`
80 on the user's :py:class:`slidge.core.session.BaseSession`.
81 """
83 max_history_fetch = 100
85 is_group = True
87 DISCO_TYPE = "text"
88 DISCO_CATEGORY = "conference"
90 STABLE_ARCHIVE = False
91 """
92 Because legacy events like reactions, editions, etc. don't all map to a stanza
93 with a proper legacy ID, slidge usually cannot guarantee the stability of the archive
94 across restarts.
96 Set this to True if you know what you're doing, but realistically, this can't
97 be set to True until archive is permanently stored on disk by slidge.
99 This is just a flag on archive responses that most clients ignore anyway.
100 """
102 _ALL_INFO_FILLED_ON_STARTUP = False
103 """
104 Set this to true if the fill_participants() / fill_participants() design does not
105 fit the legacy API, ie, no lazy loading of the participant list and history.
106 """
108 HAS_DESCRIPTION = True
109 """
110 Set this to false if the legacy network does not allow setting a description
111 for the group. In this case the description field will not be present in the
112 room configuration form.
113 """
115 HAS_SUBJECT = True
116 """
117 Set this to false if the legacy network does not allow setting a subject
118 (sometimes also called topic) for the group. In this case, as a subject is
119 recommended by :xep:`0045` ("SHALL"), the description (or the group name as
120 ultimate fallback) will be used as the room subject.
121 By setting this to false, an error will be returned when the :term:`User`
122 tries to set the room subject.
123 """
125 archive: MessageArchive
126 session: "BaseSession"
128 stored: Room
130 _participant_cls: type[LegacyParticipantType]
132 def __init__(self, session: "BaseSession", stored: Room) -> None:
133 self.session = session
134 self.xmpp = session.xmpp
135 self.stored = stored
136 self._set_logger()
137 super().__init__()
139 self.archive = MessageArchive(stored, self.xmpp.store)
141 if self._ALL_INFO_FILLED_ON_STARTUP:
142 self.stored.participants_filled = True
144 def pop_unread_xmpp_ids_up_to(self, horizon_xmpp_id: str) -> list[str]:
145 """
146 Return XMPP msg ids sent in this group up to a given XMPP msg id.
148 Plugins have no reason to use this, but it is used by slidge core
149 for legacy networks that need to mark *all* messages as read (most XMPP
150 clients only send a read marker for the latest message).
152 This has side effects: all messages up to the horizon XMPP id will be marked
153 as read in the DB. If the horizon XMPP id is not found, all messages of this
154 MUC will be marked as read.
156 :param horizon_xmpp_id: The latest message
157 :return: A list of XMPP ids if horizon_xmpp_id was not found
158 """
159 with self.xmpp.store.session() as orm:
160 assert self.stored.id is not None
161 ids = self.xmpp.store.mam.pop_unread_up_to(
162 orm, self.stored.id, horizon_xmpp_id
163 )
164 orm.commit()
165 return ids
167 def participant_from_store(
168 self, stored: Participant, contact: LegacyContact | None = None
169 ) -> LegacyParticipantType:
170 if contact is None and stored.contact is not None:
171 contact = self.session.contacts.from_store(stored.contact)
172 return self._participant_cls(self, stored=stored, contact=contact)
174 @property
175 def jid(self) -> JID:
176 return self.stored.jid
178 @jid.setter
179 def jid(self, x: JID) -> None:
180 # FIXME: without this, mypy yields
181 # "Cannot override writeable attribute with read-only property"
182 # But it does not happen for LegacyContact. WTF?
183 raise RuntimeError
185 @property
186 def legacy_id(self):
187 return self.xmpp.LEGACY_ROOM_ID_TYPE(self.stored.legacy_id)
189 def orm(self, **kwargs) -> OrmSession:
190 return self.xmpp.store.session(**kwargs)
192 @property
193 def type(self) -> MucType:
194 return self.stored.muc_type
196 @type.setter
197 def type(self, type_: MucType) -> None:
198 if self.type == type_:
199 return
200 self.update_stored_attribute(muc_type=type_)
202 @property
203 def n_participants(self):
204 return self.stored.n_participants
206 @n_participants.setter
207 def n_participants(self, n_participants: int | None) -> None:
208 if self.stored.n_participants == n_participants:
209 return
210 self.update_stored_attribute(n_participants=n_participants)
212 @property
213 def user_jid(self) -> JID:
214 return self.session.user_jid
216 def _set_logger(self) -> None:
217 self.log = logging.getLogger(f"{self.user_jid}:muc:{self}")
219 def __repr__(self) -> str:
220 return f"<MUC #{self.stored.id} '{self.name}' ({self.stored.legacy_id} - {self.jid.user})'>"
222 @property
223 def subject_date(self) -> datetime | None:
224 if self.stored.subject_date is None:
225 return None
226 return self.stored.subject_date.replace(tzinfo=UTC)
228 @subject_date.setter
229 def subject_date(self, when: datetime | None) -> None:
230 if self.subject_date == when:
231 return
232 self.update_stored_attribute(subject_date=when)
234 def __send_configuration_change(self, codes: tuple[int, ...]) -> None:
235 part = self.get_system_participant()
236 part.send_configuration_change(codes)
238 @property
239 def user_nick(self) -> str:
240 return (
241 self.stored.user_nick
242 or self.session.bookmarks.user_nick
243 or self.user_jid.node
244 )
246 @user_nick.setter
247 def user_nick(self, nick: str) -> None:
248 if nick == self.user_nick:
249 return
250 self.update_stored_attribute(user_nick=nick)
252 def add_user_resource(self, resource: str) -> None:
253 stored_set = self.get_user_resources()
254 if resource in stored_set:
255 return
256 stored_set.add(resource)
257 self.update_stored_attribute(
258 user_resources=(json.dumps(list(stored_set)) if stored_set else None)
259 )
261 def get_user_resources(self) -> set[str]:
262 stored_str = self.stored.user_resources
263 if stored_str is None:
264 return set()
265 return set(json.loads(stored_str))
267 def remove_user_resource(self, resource: str) -> None:
268 stored_set = self.get_user_resources()
269 if resource not in stored_set:
270 return
271 stored_set.remove(resource)
272 self.update_stored_attribute(
273 user_resources=(json.dumps(list(stored_set)) if stored_set else None)
274 )
276 @asynccontextmanager
277 async def lock(self, id_: str) -> AsyncIterator[None]:
278 async with self.session.lock((self.legacy_id, id_)):
279 yield
281 def get_lock(self, id_: str) -> Lock | None:
282 return self.session.get_lock((self.legacy_id, id_))
284 async def __fill_participants(self) -> None:
285 if self._ALL_INFO_FILLED_ON_STARTUP or self.participants_filled:
286 return
288 async with self.lock("fill participants"):
289 with self.xmpp.store.session(expire_on_commit=False) as orm:
290 orm.add(self.stored)
291 with orm.no_autoflush:
292 orm.refresh(self.stored, ["participants_filled"])
293 if self.participants_filled:
294 return
295 parts: list[Participant] = []
296 resources = set[str]()
297 # During fill_participants(), self.get_participant*() methods may
298 # return a participant with a conflicting nick/resource.
299 async for participant in self.fill_participants():
300 if participant.stored.resource in resources:
301 self.log.debug(
302 "Participant '%s' was yielded more than once by fill_participants(), ignoring",
303 participant.stored.resource,
304 )
305 continue
306 parts.append(participant.stored)
307 resources.add(participant.stored.resource)
308 with self.xmpp.store.session(expire_on_commit=False) as orm:
309 orm.add(self.stored)
310 # because self.fill_participants() is async, self.stored may be stale at
311 # this point, and the only thing we want to update is the participant list
312 # and the participant_filled attribute.
313 with orm.no_autoflush:
314 orm.refresh(self.stored)
315 for part in parts:
316 orm.merge(part)
317 self.stored.participants_filled = True
318 orm.commit()
320 async def get_participants(
321 self, affiliation: MucAffiliation | None = None
322 ) -> AsyncIterator[LegacyParticipantType]:
323 await self.__fill_participants()
324 with self.xmpp.store.session(expire_on_commit=False, autoflush=False) as orm:
325 self.stored = orm.merge(self.stored)
326 for db_participant in self.stored.participants:
327 if (
328 affiliation is not None
329 and db_participant.affiliation != affiliation
330 ):
331 continue
332 yield self.participant_from_store(db_participant)
334 async def __fill_history(self) -> None:
335 async with self.lock("fill history"):
336 with self.xmpp.store.session(expire_on_commit=False) as orm:
337 orm.add(self.stored)
338 with orm.no_autoflush:
339 orm.refresh(self.stored, ["history_filled"])
340 if self.stored.history_filled:
341 self.log.debug("History has already been fetched.")
342 return
343 log.debug("Fetching history for %s", self)
344 try:
345 before, after = self.archive.get_hole_bounds()
346 if before is not None:
347 before = before._replace(
348 id=self.xmpp.LEGACY_MSG_ID_TYPE(before.id) # type:ignore
349 )
350 if after is not None:
351 after = after._replace(
352 id=self.xmpp.LEGACY_MSG_ID_TYPE(after.id) # type:ignore
353 )
354 await self.backfill(before, after)
355 except NotImplementedError:
356 return
357 except Exception as e:
358 self.log.exception("Could not backfill", exc_info=e)
360 self.stored.history_filled = True
361 self.commit(merge=True)
363 def _get_disco_name(self) -> str | None:
364 return self.name
366 @property
367 def name(self) -> str | None:
368 return self.stored.name
370 @name.setter
371 def name(self, n: str | None) -> None:
372 if self.name == n:
373 return
374 self.update_stored_attribute(name=n)
375 self._set_logger()
376 self.__send_configuration_change((104,))
378 @property
379 def description(self) -> str:
380 return self.stored.description or ""
382 @description.setter
383 def description(self, d: str) -> None:
384 if self.description == d:
385 return
386 self.update_stored_attribute(description=d)
387 self.__send_configuration_change((104,))
389 def on_presence_unavailable(self, p: Presence) -> None:
390 pto = p.get_to()
391 if pto.bare != self.jid.bare:
392 return
394 pfrom = p.get_from()
395 if pfrom.bare != self.user_jid.bare:
396 return
397 if (resource := pfrom.resource) in self.get_user_resources():
398 if pto.resource != self.user_nick:
399 self.log.debug(
400 "Received 'leave group' request but with wrong nickname. %s", p
401 )
402 self.remove_user_resource(resource)
403 else:
404 self.log.debug(
405 "Received 'leave group' request but resource was not listed. %s", p
406 )
408 async def update_info(self) -> None:
409 """
410 Fetch information about this group from the legacy network
412 This is awaited on MUC instantiation, and should be overridden to
413 update the attributes of the group chat, like title, subject, number
414 of participants etc.
416 To take advantage of the slidge avatar cache, you can check the .avatar
417 property to retrieve the "legacy file ID" of the cached avatar. If there
418 is no change, you should not call
419 :py:meth:`slidge.core.mixins.avatar.AvatarMixin.set_avatar()` or
420 attempt to modify
421 the :attr:.avatar property.
422 """
423 raise NotImplementedError
425 async def backfill(
426 self,
427 after: HoleBound | None = None,
428 before: HoleBound | None = None,
429 ) -> None:
430 """
431 Override this if the legacy network provide server-side group archives.
433 In it, send history messages using ``self.get_participant(xxx).send_xxxx``,
434 with the ``archive_only=True`` kwarg. This is only called once per slidge
435 run for a given group.
437 :param after: Fetch messages after this one.
438 If ``None``, slidge's local archive was empty before start-up,
439 ie, no history was ever fetched for this room since the user registered.
440 It's up to gateway implementations to decide how far to fetch messages before
441 the user registered.
442 If not ``None``, slidge has some messages in this archive, and
443 the gateway shall try to fetch history up to (and excluding) this message
444 to avoid "holes" in the history of this group.
445 :param before: Fetch messages before this one.
446 If ``None``, the gateway shall fetch all messages up to the most recent one.
447 If not ``None``, slidge has already archived some live messages
448 it received during its lifetime, and there is no need to query the legacy
449 network for any message after (and including) this one.
450 """
451 raise NotImplementedError
453 async def fill_participants(self) -> AsyncIterator[LegacyParticipantType]:
454 """
455 This method should yield the list of all members of this group.
457 Typically, use ``participant = self.get_participant()``, self.get_participant_by_contact(),
458 of self.get_user_participant(), and update their affiliation, hats, etc.
459 before yielding them.
460 """
461 return
462 yield
464 @property
465 def subject(self) -> str:
466 return self.stored.subject or ""
468 @subject.setter
469 def subject(self, s: str) -> None:
470 if s == self.subject:
471 return
473 self.update_stored_attribute(subject=s)
474 self.__get_subject_setter_participant().set_room_subject(
475 s, None, self.subject_date, False
476 )
478 @property
479 def is_anonymous(self) -> bool:
480 return self.type == MucType.CHANNEL
482 @property
483 def subject_setter(self) -> str | None:
484 return self.stored.subject_setter
486 @subject_setter.setter
487 def subject_setter(self, subject_setter: SubjectSetterType) -> None:
488 if isinstance(subject_setter, LegacyContact):
489 subject_setter = subject_setter.name
490 elif isinstance(subject_setter, LegacyParticipant):
491 subject_setter = subject_setter.nickname
493 if subject_setter == self.subject_setter:
494 return
495 assert isinstance(subject_setter, str | None)
496 self.update_stored_attribute(subject_setter=subject_setter)
498 def __get_subject_setter_participant(self) -> LegacyParticipant:
499 if self.subject_setter is None:
500 return self.get_system_participant()
501 return self._participant_cls(
502 self,
503 Participant(nickname=self.subject_setter, occupant_id="subject-setter"),
504 )
506 def features(self) -> list[str]:
507 features = [
508 "http://jabber.org/protocol/muc",
509 "http://jabber.org/protocol/muc#stable_id",
510 "http://jabber.org/protocol/muc#self-ping-optimization",
511 "urn:xmpp:mam:2",
512 "urn:xmpp:mam:2#extended",
513 "urn:xmpp:sid:0",
514 "muc_persistent",
515 "vcard-temp",
516 "urn:xmpp:ping",
517 "urn:xmpp:occupant-id:0",
518 "jabber:iq:register",
519 self.xmpp.plugin["xep_0425"].stanza.NS,
520 ]
521 if self.type == MucType.GROUP:
522 features.extend(["muc_membersonly", "muc_nonanonymous", "muc_hidden"])
523 elif self.type == MucType.CHANNEL:
524 features.extend(["muc_open", "muc_semianonymous", "muc_public"])
525 elif self.type == MucType.CHANNEL_NON_ANONYMOUS:
526 features.extend(["muc_open", "muc_nonanonymous", "muc_public"])
527 return features
529 async def extended_features(self) -> list[Form]:
530 is_group = self.type == MucType.GROUP
532 form = self.xmpp.plugin["xep_0004"].make_form(ftype="result")
534 form.add_field(
535 "FORM_TYPE", "hidden", value="http://jabber.org/protocol/muc#roominfo"
536 )
537 form.add_field("muc#roomconfig_persistentroom", "boolean", value=True)
538 form.add_field("muc#roomconfig_changesubject", "boolean", value=False)
539 form.add_field("muc#maxhistoryfetch", value=str(self.max_history_fetch))
540 form.add_field("muc#roominfo_subjectmod", "boolean", value=False)
542 if self.stored.id is not None and (
543 self._ALL_INFO_FILLED_ON_STARTUP or self.stored.participants_filled
544 ):
545 with self.xmpp.store.session() as orm:
546 n = orm.scalar(
547 sa.select(sa.func.count(Participant.id)).filter_by(
548 room_id=self.stored.id
549 )
550 )
551 else:
552 n = self.n_participants
553 if n is not None:
554 form.add_field("muc#roominfo_occupants", value=str(n))
556 if d := self.description:
557 form.add_field("muc#roominfo_description", value=d)
559 if s := self.subject:
560 form.add_field("muc#roominfo_subject", value=s)
562 if name := self.name:
563 form.add_field("muc#roomconfig_roomname", value=name)
565 if self._set_avatar_task is not None:
566 await self._set_avatar_task
567 avatar = self.get_avatar()
568 if avatar and (h := avatar.id):
569 form.add_field(
570 "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1", value=h
571 )
572 form.add_field("muc#roominfo_avatarhash", "text-multi", value=[h])
574 form.add_field("muc#roomconfig_membersonly", "boolean", value=is_group)
575 form.add_field(
576 "muc#roomconfig_whois",
577 "list-single",
578 value="moderators" if self.is_anonymous else "anyone",
579 )
580 form.add_field("muc#roomconfig_publicroom", "boolean", value=not is_group)
581 form.add_field("muc#roomconfig_allowpm", "boolean", value=False)
583 r = [form]
585 if reaction_form := await self.restricted_emoji_extended_feature():
586 r.append(reaction_form)
588 return r
590 def shutdown(self) -> None:
591 _, user_jid = escape_nickname(self.jid, self.user_nick)
592 for user_full_jid in self.user_full_jids():
593 presence = self.xmpp.make_presence(
594 pfrom=user_jid, pto=user_full_jid, ptype="unavailable"
595 )
596 presence["muc"]["affiliation"] = "none"
597 presence["muc"]["role"] = "none"
598 presence["muc"]["status_codes"] = {110, 332}
599 presence.send()
601 def user_full_jids(self) -> Iterable[JID]:
602 for r in self.get_user_resources():
603 j = JID(self.user_jid)
604 j.resource = r
605 yield j
607 @property
608 def user_muc_jid(self) -> JID:
609 _, user_muc_jid = escape_nickname(self.jid, self.user_nick)
610 return user_muc_jid
612 async def echo(
613 self, msg: Message, legacy_msg_id: LegacyMessageType | None = None
614 ) -> str:
615 msg.set_from(self.user_muc_jid)
616 if legacy_msg_id:
617 msg["stanza_id"]["id"] = self.session.legacy_to_xmpp_msg_id(legacy_msg_id)
618 else:
619 msg["stanza_id"]["id"] = str(uuid.uuid4())
620 msg["stanza_id"]["by"] = self.jid
622 user_part = await self.get_user_participant()
623 msg["occupant-id"]["id"] = user_part.stored.occupant_id
625 self.archive.add(msg, user_part)
627 for user_full_jid in self.user_full_jids():
628 self.log.debug("Echoing to %s", user_full_jid)
629 msg = copy(msg)
630 msg.set_to(user_full_jid)
632 msg.send()
634 return msg["stanza_id"]["id"]
636 def _post_avatar_update(self, cached_avatar: "CachedAvatar | None") -> None:
637 self.__send_configuration_change((104,))
638 self._send_room_presence()
640 def _send_room_presence(self, user_full_jid: JID | None = None) -> None:
641 if user_full_jid is None:
642 tos = self.user_full_jids()
643 else:
644 tos = [user_full_jid]
645 for to in tos:
646 p = self.xmpp.make_presence(pfrom=self.jid, pto=to)
647 if (avatar := self.get_avatar()) and (h := avatar.id):
648 p["vcard_temp_update"]["photo"] = h
649 else:
650 p["vcard_temp_update"]["photo"] = ""
651 p.send()
653 @timeit
654 async def join(self, join_presence: Presence) -> None:
655 user_full_jid = join_presence.get_from()
656 requested_nickname = join_presence.get_to().resource
657 client_resource = user_full_jid.resource
659 if client_resource in self.get_user_resources():
660 self.log.debug("Received join from a resource that is already joined.")
662 if not requested_nickname or not client_resource:
663 raise XMPPError("jid-malformed", by=self.jid)
665 self.add_user_resource(client_resource)
667 self.log.debug(
668 "Resource %s of %s wants to join room %s with nickname %s",
669 client_resource,
670 self.user_jid,
671 self.legacy_id,
672 requested_nickname,
673 )
675 user_nick = self.user_nick
676 user_participant = None
677 async for participant in self.get_participants():
678 if participant.is_user:
679 user_participant = participant
680 continue
681 participant.send_initial_presence(full_jid=user_full_jid)
683 if user_participant is None:
684 user_participant = await self.get_user_participant()
685 with self.xmpp.store.session() as orm:
686 orm.add(self.stored)
687 with orm.no_autoflush:
688 orm.refresh(self.stored, ["participants"])
689 if not user_participant.is_user:
690 self.log.warning("is_user flag not set on user_participant")
691 user_participant.is_user = True
692 user_participant.send_initial_presence(
693 user_full_jid,
694 presence_id=join_presence["id"],
695 nick_change=user_nick != requested_nickname,
696 )
698 history_params = join_presence["muc_join"]["history"]
699 maxchars = int_or_none(history_params["maxchars"])
700 maxstanzas = int_or_none(history_params["maxstanzas"])
701 seconds = int_or_none(history_params["seconds"])
702 try:
703 since = self.xmpp.plugin["xep_0082"].parse(history_params["since"])
704 except ValueError:
705 since = None
706 if seconds is not None:
707 since = datetime.now() - timedelta(seconds=seconds)
708 if equals_zero(maxchars) or equals_zero(maxstanzas):
709 log.debug("Joining client does not want any old-school MUC history-on-join")
710 else:
711 self.log.debug("Old school history fill")
712 await self.__fill_history()
713 await self.__old_school_history(
714 user_full_jid,
715 maxchars=maxchars,
716 maxstanzas=maxstanzas,
717 since=since,
718 )
719 if self.HAS_SUBJECT:
720 subject = self.subject or ""
721 else:
722 subject = self.description or self.name or ""
723 self.__get_subject_setter_participant().set_room_subject(
724 subject,
725 user_full_jid,
726 self.subject_date,
727 )
728 if t := self._set_avatar_task:
729 await t
730 self._send_room_presence(user_full_jid)
732 async def get_user_participant(self, **kwargs) -> "LegacyParticipantType":
733 """
734 Get the participant representing the gateway user
736 :param kwargs: additional parameters for the :class:`.Participant`
737 construction (optional)
738 :return:
739 """
740 p = await self.get_participant(self.user_nick, is_user=True, **kwargs)
741 self.__store_participant(p)
742 return p
744 def __store_participant(self, p: "LegacyParticipantType") -> None:
745 if self.get_lock("fill participants"):
746 return
747 try:
748 p.commit(merge=True)
749 except IntegrityError as e:
750 if self._ALL_INFO_FILLED_ON_STARTUP:
751 log.debug("ℂould not store participant: %r", e)
752 with self.orm(expire_on_commit=False) as orm:
753 self.stored = self.xmpp.store.rooms.get(
754 orm, self.user_pk, legacy_id=str(self.legacy_id)
755 )
756 p.stored.room = self.stored
757 orm.add(p.stored)
758 orm.commit()
759 else:
760 log.debug("ℂould not store participant: %r", e)
762 @overload
763 async def get_participant(self, nickname: str) -> "LegacyParticipantType": ...
765 @overload
766 async def get_participant(self, *, occupant_id: str) -> "LegacyParticipantType": ...
768 @overload
769 async def get_participant(
770 self, *, occupant_id: str, create: Literal[False]
771 ) -> "LegacyParticipantType | None": ...
773 @overload
774 async def get_participant(
775 self, *, occupant_id: str, create: Literal[True]
776 ) -> "LegacyParticipantType": ...
778 @overload
779 async def get_participant(
780 self, nickname: str, *, occupant_id: str
781 ) -> "LegacyParticipantType": ...
783 @overload
784 async def get_participant(
785 self, nickname: str, *, create: Literal[False]
786 ) -> "LegacyParticipantType | None": ...
788 @overload
789 async def get_participant(
790 self, nickname: str, *, create: Literal[True]
791 ) -> "LegacyParticipantType": ...
793 @overload
794 async def get_participant(
795 self,
796 nickname: str,
797 *,
798 create: Literal[True],
799 is_user: bool,
800 fill_first: bool,
801 store: bool,
802 ) -> "LegacyParticipantType": ...
804 @overload
805 async def get_participant(
806 self,
807 nickname: str,
808 *,
809 create: Literal[False],
810 is_user: bool,
811 fill_first: bool,
812 store: bool,
813 ) -> "LegacyParticipantType | None": ...
815 @overload
816 async def get_participant(
817 self,
818 nickname: str,
819 *,
820 create: bool,
821 fill_first: bool,
822 ) -> "LegacyParticipantType | None": ...
824 async def get_participant(
825 self,
826 nickname: str | None = None,
827 *,
828 create: bool = True,
829 is_user: bool = False,
830 fill_first: bool = False,
831 store: bool = True,
832 occupant_id: str | None = None,
833 ) -> "LegacyParticipantType | None":
834 """
835 Get a participant by their nickname.
837 In non-anonymous groups, you probably want to use
838 :meth:`.LegacyMUC.get_participant_by_contact` instead.
840 :param nickname: Nickname of the participant (used as resource part in the MUC)
841 :param create: By default, a participant is created if necessary. Set this to
842 False to return None if participant was not created before.
843 :param is_user: Whether this participant is the slidge user.
844 :param fill_first: Ensure :meth:`.LegacyMUC.fill_participants()` has been called
845 first (internal use by slidge, plugins should not need that)
846 :param store: persistently store the user in the list of MUC participants
847 :param occupant_id: optionally, specify the unique ID for this participant, cf
848 xep:`0421`
849 :return: A participant of this room.
850 """
851 if not any((nickname, occupant_id)):
852 raise TypeError("You must specify either a nickname or an occupant ID")
853 if fill_first:
854 await self.__fill_participants()
855 if not self._ALL_INFO_FILLED_ON_STARTUP or self.stored.id is not None:
856 with self.xmpp.store.session(expire_on_commit=False) as orm:
857 if occupant_id is not None:
858 stored = (
859 orm.query(Participant)
860 .filter(
861 Participant.room == self.stored,
862 Participant.occupant_id == occupant_id,
863 )
864 .one_or_none()
865 )
866 elif nickname is not None:
867 stored = (
868 orm.query(Participant)
869 .filter(
870 Participant.room == self.stored,
871 (Participant.nickname == nickname)
872 | (Participant.resource == nickname),
873 )
874 .one_or_none()
875 )
876 else:
877 raise RuntimeError("NEVER")
878 if stored is not None:
879 if occupant_id and occupant_id != stored.occupant_id:
880 warnings.warn(
881 f"Occupant ID mismatch in get_participant(): {occupant_id} vs {stored.occupant_id}",
882 )
883 part = self.participant_from_store(stored)
884 if occupant_id and nickname and nickname != stored.nickname:
885 stored.nickname = nickname
886 orm.add(stored)
887 orm.commit()
888 return part
890 if not create:
891 return None
893 if occupant_id is None:
894 occupant_id = "slidge-user" if is_user else str(uuid.uuid4())
896 if nickname is None:
897 nickname = occupant_id
899 if not self.xmpp.store.rooms.nick_available(orm, self.stored.id, nickname):
900 nickname = f"{nickname} ({occupant_id})"
901 if is_user:
902 self.user_nick = nickname
904 p = self._participant_cls(
905 self,
906 Participant(
907 room=self.stored,
908 nickname=nickname or occupant_id,
909 is_user=is_user,
910 occupant_id=occupant_id,
911 ),
912 )
913 if store:
914 self.__store_participant(p)
915 if (
916 not self.get_lock("fill participants")
917 and not self.get_lock("fill history")
918 and self.stored.participants_filled
919 and not p.is_user
920 and not p.is_system
921 ):
922 p.send_affiliation_change()
923 return p
925 def get_system_participant(self) -> "LegacyParticipantType":
926 """
927 Get a pseudo-participant, representing the room itself
929 Can be useful for events that cannot be mapped to a participant,
930 e.g. anonymous moderation events, or announces from the legacy
931 service
932 :return:
933 """
934 return self._participant_cls(
935 self, Participant(occupant_id="room"), is_system=True
936 )
938 @overload
939 async def get_participant_by_contact(
940 self, c: "LegacyContact[Any]"
941 ) -> "LegacyParticipantType": ...
943 @overload
944 async def get_participant_by_contact(
945 self, c: "LegacyContact[Any]", *, occupant_id: str | None = None
946 ) -> "LegacyParticipantType": ...
948 @overload
949 async def get_participant_by_contact(
950 self,
951 c: "LegacyContact[Any]",
952 *,
953 create: Literal[False],
954 occupant_id: str | None,
955 ) -> "LegacyParticipantType | None": ...
957 @overload
958 async def get_participant_by_contact(
959 self,
960 c: "LegacyContact[Any]",
961 *,
962 create: Literal[True],
963 occupant_id: str | None,
964 ) -> "LegacyParticipantType": ...
966 async def get_participant_by_contact(
967 self, c: "LegacyContact", *, create: bool = True, occupant_id: str | None = None
968 ) -> "LegacyParticipantType | None":
969 """
970 Get a non-anonymous participant.
972 This is what should be used in non-anonymous groups ideally, to ensure
973 that the Contact jid is associated to this participant
975 :param c: The :class:`.LegacyContact` instance corresponding to this contact
976 :param create: Creates the participant if it does not exist.
977 :param occupant_id: Optionally, specify a unique occupant ID (:xep:`0421`) for
978 this participant.
979 :return:
980 """
981 await self.session.contacts.ready
983 if not self._ALL_INFO_FILLED_ON_STARTUP or self.stored.id is not None:
984 with self.xmpp.store.session() as orm:
985 self.stored = orm.merge(self.stored)
986 stored = (
987 orm.query(Participant)
988 .filter_by(contact=c.stored, room=self.stored)
989 .one_or_none()
990 )
991 if stored is None:
992 if occupant_id is not None:
993 stored = (
994 orm.query(Participant)
995 .filter_by(
996 occupant_id=occupant_id,
997 room=self.stored,
998 contact_id=None,
999 )
1000 .one_or_none()
1001 )
1002 if stored is not None:
1003 self.log.debug(
1004 "Updating the contact of a previously anonymous participant"
1005 )
1006 stored.contact_id = c.stored.id
1007 orm.add(stored)
1008 orm.commit()
1009 return self.participant_from_store(stored=stored, contact=c)
1010 if not create:
1011 return None
1012 else:
1013 if occupant_id and stored.occupant_id != occupant_id:
1014 warnings.warn(
1015 f"Occupant ID mismatch: {occupant_id} vs {stored.occupant_id}",
1016 )
1017 return self.participant_from_store(stored=stored, contact=c)
1019 nickname = c.name or unescape_node(c.jid.node)
1021 if self.stored.id is None:
1022 nick_available = True
1023 else:
1024 with self.xmpp.store.session() as orm:
1025 nick_available = self.xmpp.store.rooms.nick_available(
1026 orm, self.stored.id, nickname
1027 )
1029 if not nick_available:
1030 self.log.debug("Nickname conflict")
1031 nickname = f"{nickname} ({c.jid.node})"
1032 p = self._participant_cls(
1033 self,
1034 Participant(
1035 nickname=nickname,
1036 room=self.stored,
1037 occupant_id=occupant_id or str(c.jid),
1038 ),
1039 contact=c,
1040 )
1042 self.__store_participant(p)
1043 # FIXME: this is not great but given the current design,
1044 # during participants fill and history backfill we do not
1045 # want to send presence, because we might :update affiliation
1046 # and role afterwards.
1047 # We need a refactor of the MUC class… later™
1048 if (
1049 self.stored.participants_filled
1050 and not self.get_lock("fill participants")
1051 and not self.get_lock("fill history")
1052 ):
1053 p.send_last_presence(force=True, no_cache_online=True)
1054 return p
1056 @overload
1057 async def get_participant_by_legacy_id(
1058 self, legacy_id: LegacyUserIdType
1059 ) -> "LegacyParticipantType": ...
1061 @overload
1062 async def get_participant_by_legacy_id(
1063 self,
1064 legacy_id: LegacyUserIdType,
1065 *,
1066 occupant_id: str | None,
1067 create: Literal[True],
1068 ) -> "LegacyParticipantType": ...
1070 @overload
1071 async def get_participant_by_legacy_id(
1072 self,
1073 legacy_id: LegacyUserIdType,
1074 *,
1075 occupant_id: str | None,
1076 ) -> "LegacyParticipantType": ...
1078 @overload
1079 async def get_participant_by_legacy_id(
1080 self,
1081 legacy_id: LegacyUserIdType,
1082 *,
1083 occupant_id: str | None,
1084 create: Literal[False],
1085 ) -> "LegacyParticipantType | None": ...
1087 async def get_participant_by_legacy_id(
1088 self,
1089 legacy_id: LegacyUserIdType,
1090 *,
1091 occupant_id: str | None = None,
1092 create: bool = True,
1093 ) -> "LegacyParticipantType":
1094 try:
1095 c = await self.session.contacts.by_legacy_id(legacy_id)
1096 except ContactIsUser:
1097 return await self.get_user_participant(occupant_id=occupant_id)
1098 return await self.get_participant_by_contact( # type:ignore[call-overload]
1099 c, create=create, occupant_id=occupant_id
1100 )
1102 def remove_participant(
1103 self,
1104 p: "LegacyParticipantType",
1105 kick: bool = False,
1106 ban: bool = False,
1107 reason: str | None = None,
1108 ) -> None:
1109 """
1110 Call this when a participant leaves the room
1112 :param p: The participant
1113 :param kick: Whether the participant left because they were kicked
1114 :param ban: Whether the participant left because they were banned
1115 :param reason: Optionally, a reason why the participant was removed.
1116 """
1117 if kick and ban:
1118 raise TypeError("Either kick or ban")
1119 with self.xmpp.store.session() as orm:
1120 orm.delete(p.stored)
1121 orm.commit()
1122 if kick:
1123 codes = {307}
1124 elif ban:
1125 codes = {301}
1126 else:
1127 codes = None
1128 presence = p._make_presence(ptype="unavailable", status_codes=codes)
1129 p.stored.affiliation = "outcast" if ban else "none"
1130 p.stored.role = "none"
1131 if reason:
1132 presence["muc"].set_item_attr("reason", reason)
1133 p._send(presence)
1135 def rename_participant(self, old_nickname: str, new_nickname: str) -> None:
1136 with self.xmpp.store.session() as orm:
1137 stored = (
1138 orm.query(Participant)
1139 .filter_by(room=self.stored, nickname=old_nickname)
1140 .one_or_none()
1141 )
1142 if stored is None:
1143 self.log.debug("Tried to rename a participant that we didn't know")
1144 return
1145 p = self.participant_from_store(stored)
1146 if p.nickname == old_nickname:
1147 p.nickname = new_nickname
1149 async def __old_school_history(
1150 self,
1151 full_jid: JID,
1152 maxchars: int | None = None,
1153 maxstanzas: int | None = None,
1154 seconds: int | None = None,
1155 since: datetime | None = None,
1156 ) -> None:
1157 """
1158 Old-style history join (internal slidge use)
1160 :param full_jid:
1161 :param maxchars:
1162 :param maxstanzas:
1163 :param seconds:
1164 :param since:
1165 :return:
1166 """
1167 if since is None:
1168 if seconds is None:
1169 start_date = datetime.now(tz=UTC) - timedelta(days=1)
1170 else:
1171 start_date = datetime.now(tz=UTC) - timedelta(seconds=seconds)
1172 else:
1173 start_date = since or datetime.now(tz=UTC) - timedelta(days=1)
1175 for h_msg in self.archive.get_all(
1176 start_date=start_date, end_date=None, last_page_n=maxstanzas
1177 ):
1178 msg = h_msg.stanza_component_ns
1179 msg["delay"]["stamp"] = h_msg.when
1180 msg.set_to(full_jid)
1181 self.xmpp.send(msg, False)
1183 async def send_mam(self, iq: Iq) -> None:
1184 await self.__fill_history()
1186 form_values = iq["mam"]["form"].get_values()
1188 start_date = str_to_datetime_or_none(form_values.get("start"))
1189 end_date = str_to_datetime_or_none(form_values.get("end"))
1191 after_id = form_values.get("after-id")
1192 before_id = form_values.get("before-id")
1194 sender = form_values.get("with")
1196 ids = form_values.get("ids") or ()
1198 if max_str := iq["mam"]["rsm"]["max"]:
1199 try:
1200 max_results = int(max_str)
1201 except ValueError:
1202 max_results = None
1203 else:
1204 max_results = None
1206 after_id_rsm = iq["mam"]["rsm"]["after"]
1207 after_id = after_id_rsm or after_id
1209 before_rsm = iq["mam"]["rsm"]["before"]
1210 if before_rsm is not None and max_results is not None:
1211 last_page_n = max_results
1212 # - before_rsm is True means the empty element <before />, which means
1213 # "last page in chronological order", cf https://xmpp.org/extensions/xep-0059.html#backwards
1214 # - before_rsm == "an ID" means <before>an ID</before>
1215 if before_rsm is not True:
1216 before_id = before_rsm
1217 else:
1218 last_page_n = None
1220 first = None
1221 last = None
1222 count = 0
1224 it = self.archive.get_all(
1225 start_date,
1226 end_date,
1227 before_id,
1228 after_id,
1229 ids,
1230 last_page_n,
1231 sender,
1232 bool(iq["mam"]["flip_page"]),
1233 )
1235 for history_msg in it:
1236 last = xmpp_id = history_msg.id
1237 if first is None:
1238 first = xmpp_id
1240 wrapper_msg = self.xmpp.make_message(mfrom=self.jid, mto=iq.get_from())
1241 wrapper_msg["mam_result"]["queryid"] = iq["mam"]["queryid"]
1242 wrapper_msg["mam_result"]["id"] = xmpp_id
1243 wrapper_msg["mam_result"].append(history_msg.forwarded())
1245 wrapper_msg.send()
1246 count += 1
1248 if max_results and count == max_results:
1249 break
1251 if max_results:
1252 try:
1253 next(it)
1254 except StopIteration:
1255 complete = True
1256 else:
1257 complete = False
1258 else:
1259 complete = True
1261 reply = iq.reply()
1262 if not self.STABLE_ARCHIVE:
1263 reply["mam_fin"]["stable"] = "false"
1264 if complete:
1265 reply["mam_fin"]["complete"] = "true"
1266 reply["mam_fin"]["rsm"]["first"] = first
1267 reply["mam_fin"]["rsm"]["last"] = last
1268 reply["mam_fin"]["rsm"]["count"] = str(count)
1269 reply.send()
1271 async def send_mam_metadata(self, iq: Iq) -> None:
1272 await self.__fill_history()
1273 await self.archive.send_metadata(iq)
1275 async def kick_resource(self, r: str) -> None:
1276 """
1277 Kick a XMPP client of the user. (slidge internal use)
1279 :param r: The resource to kick
1280 """
1281 pto = JID(self.user_jid)
1282 pto.resource = r
1283 p = self.xmpp.make_presence(
1284 pfrom=(await self.get_user_participant()).jid, pto=pto
1285 )
1286 p["type"] = "unavailable"
1287 p["muc"]["affiliation"] = "none"
1288 p["muc"]["role"] = "none"
1289 p["muc"]["status_codes"] = {110, 333}
1290 p.send()
1292 async def __get_bookmark(self) -> Item | None:
1293 item = Item()
1294 item["id"] = self.jid
1296 iq = Iq(stype="get", sfrom=self.user_jid, sto=self.user_jid)
1297 iq["pubsub"]["items"]["node"] = self.xmpp["xep_0402"].stanza.NS
1298 iq["pubsub"]["items"].append(item)
1300 try:
1301 ans = await self.xmpp["xep_0356"].send_privileged_iq(iq)
1302 if len(ans["pubsub"]["items"]) != 1:
1303 return None
1304 # this below creates the item if it wasn't here already
1305 # (slixmpp annoying magic)
1306 item = ans["pubsub"]["items"]["item"]
1307 item["id"] = self.jid
1308 return item
1309 except IqTimeout:
1310 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: timeout")
1311 return None
1312 except IqError as exc:
1313 warnings.warn(f"Cannot fetch bookmark for {self.user_jid}: {exc}")
1314 return None
1315 except PermissionError:
1316 warnings.warn(
1317 "IQ privileges (XEP0356) are not set, we cannot fetch the user bookmarks"
1318 )
1319 return None
1321 async def add_to_bookmarks(
1322 self,
1323 auto_join: bool = True,
1324 preserve: bool = True,
1325 pin: bool | None = None,
1326 notify: WhenLiteral | None = None,
1327 ) -> None:
1328 """
1329 Add the MUC to the user's XMPP bookmarks (:xep:`0402')
1331 This requires that slidge has the IQ privileged set correctly
1332 on the XMPP server
1334 :param auto_join: whether XMPP clients should automatically join
1335 this MUC on startup. In theory, XMPP clients will receive
1336 a "push" notification when this is called, and they will
1337 join if they are online.
1338 :param preserve: preserve auto-join and bookmarks extensions
1339 set by the user outside slidge
1340 :param pin: Pin the group chat bookmark :xep:`0469`. Requires privileged entity.
1341 If set to ``None`` (default), the bookmark pinning status will be untouched.
1342 :param notify: Chat notification setting: :xep:`0492`. Requires privileged entity.
1343 If set to ``None`` (default), the setting will be untouched. Only the "global"
1344 notification setting is supported (ie, per client type is not possible).
1345 """
1346 existing = await self.__get_bookmark() if preserve else None
1348 new = Item()
1349 new["id"] = self.jid
1350 new["conference"]["nick"] = self.user_nick
1352 if existing is None:
1353 change = True
1354 new["conference"]["autojoin"] = auto_join
1355 else:
1356 change = False
1357 new["conference"]["autojoin"] = existing["conference"]["autojoin"]
1359 existing_extensions = existing is not None and existing[
1360 "conference"
1361 ].get_plugin("extensions", check=True)
1363 # preserving extensions we don't know about is a MUST
1364 if existing_extensions:
1365 assert existing is not None
1366 for el in existing["conference"]["extensions"].xml:
1367 if el.tag.startswith(f"{{{NOTIFY_NS}}}"):
1368 if notify is not None:
1369 continue
1370 if el.tag.startswith(f"{{{PINNING_NS}}}"):
1371 if pin is not None:
1372 continue
1373 new["conference"]["extensions"].append(el)
1375 if pin is not None:
1376 if existing_extensions:
1377 assert existing is not None
1378 existing_pin = (
1379 existing["conference"]["extensions"].get_plugin(
1380 "pinned", check=True
1381 )
1382 is not None
1383 )
1384 if existing_pin != pin:
1385 change = True
1386 new["conference"]["extensions"]["pinned"] = pin
1388 if notify is not None:
1389 new["conference"]["extensions"].enable("notify")
1390 if existing_extensions:
1391 assert existing is not None
1392 existing_notify = existing["conference"]["extensions"].get_plugin(
1393 "notify", check=True
1394 )
1395 if existing_notify is None:
1396 change = True
1397 else:
1398 if existing_notify.get_config() != notify:
1399 change = True
1400 for el in existing_notify:
1401 new["conference"]["extensions"]["notify"].append(el)
1402 new["conference"]["extensions"]["notify"].configure(notify)
1404 if change:
1405 iq = Iq(stype="set", sfrom=self.user_jid, sto=self.user_jid)
1406 iq["pubsub"]["publish"]["node"] = self.xmpp["xep_0402"].stanza.NS
1407 iq["pubsub"]["publish"].append(new)
1409 iq["pubsub"]["publish_options"] = _BOOKMARKS_OPTIONS
1411 try:
1412 await self.xmpp["xep_0356"].send_privileged_iq(iq)
1413 except PermissionError:
1414 warnings.warn(
1415 "IQ privileges (XEP0356) are not set, we cannot add bookmarks for the user"
1416 )
1417 # fallback by forcing invitation
1418 bookmark_add_fail = True
1419 except IqError as e:
1420 warnings.warn(
1421 f"Something went wrong while trying to set the bookmarks: {e}"
1422 )
1423 # fallback by forcing invitation
1424 bookmark_add_fail = True
1425 else:
1426 bookmark_add_fail = False
1427 else:
1428 self.log.debug("Bookmark does not need updating.")
1429 return
1431 if bookmark_add_fail:
1432 self.session.send_gateway_invite(
1433 self,
1434 reason="This group could not be added automatically for you, most"
1435 "likely because this gateway is not configured as a privileged entity. "
1436 "Contact your administrator.",
1437 )
1438 elif existing is None and self.session.user.preferences.get(
1439 "always_invite_when_adding_bookmarks", True
1440 ):
1441 self.session.send_gateway_invite(
1442 self,
1443 reason="The gateway is configured to always send invitations for groups.",
1444 )
1446 async def on_avatar(self, data: bytes | None, mime: str | None) -> int | str | None:
1447 """
1448 Called when the user tries to set the avatar of the room from an XMPP
1449 client.
1451 If the set avatar operation is completed, should return a legacy image
1452 unique identifier. In this case the MUC avatar will be immediately
1453 updated on the XMPP side.
1455 If data is not None and this method returns None, then we assume that
1456 self.set_avatar() will be called elsewhere, eg triggered by a legacy
1457 room update event.
1459 :param data: image data or None if the user meant to remove the avatar
1460 :param mime: the mime type of the image. Since this is provided by
1461 the XMPP client, there is no guarantee that this is valid or
1462 correct.
1463 :return: A unique avatar identifier, which will trigger
1464 :py:meth:`slidge.group.room.LegacyMUC.set_avatar`. Alternatively, None, if
1465 :py:meth:`.LegacyMUC.set_avatar` is meant to be awaited somewhere else.
1466 """
1467 raise NotImplementedError
1469 admin_set_avatar = deprecated("LegacyMUC.on_avatar", on_avatar)
1471 async def on_set_affiliation(
1472 self,
1473 contact: "LegacyContact",
1474 affiliation: MucAffiliation,
1475 reason: str | None,
1476 nickname: str | None,
1477 ) -> None:
1478 """
1479 Triggered when the user requests changing the affiliation of a contact
1480 for this group.
1482 Examples: promotion them to moderator, ban (affiliation=outcast).
1484 :param contact: The contact whose affiliation change is requested
1485 :param affiliation: The new affiliation
1486 :param reason: A reason for this affiliation change
1487 :param nickname:
1488 """
1489 raise NotImplementedError
1491 async def on_kick(self, contact: "LegacyContact", reason: str | None) -> None:
1492 """
1493 Triggered when the user requests changing the role of a contact
1494 to "none" for this group. Action commonly known as "kick".
1496 :param contact: Contact to be kicked
1497 :param reason: A reason for this kick
1498 """
1499 raise NotImplementedError
1501 async def on_set_config(
1502 self,
1503 name: str | None,
1504 description: str | None,
1505 ) -> None:
1506 """
1507 Triggered when the user requests changing the room configuration.
1508 Only title and description can be changed at the moment.
1510 The legacy module is responsible for updating :attr:`.title` and/or
1511 :attr:`.description` of this instance.
1513 If :attr:`.HAS_DESCRIPTION` is set to False, description will always
1514 be ``None``.
1516 :param name: The new name of the room.
1517 :param description: The new description of the room.
1518 """
1519 raise NotImplementedError
1521 async def on_destroy_request(self, reason: str | None) -> None:
1522 """
1523 Triggered when the user requests room destruction.
1525 :param reason: Optionally, a reason for the destruction
1526 """
1527 raise NotImplementedError
1529 async def parse_mentions(self, text: str) -> list[Mention]:
1530 with self.xmpp.store.session() as orm:
1531 await self.__fill_participants()
1532 orm.add(self.stored)
1533 participants = {p.nickname: p for p in self.stored.participants}
1535 if len(participants) == 0:
1536 return []
1538 result = []
1539 for match in re.finditer(
1540 "|".join(
1541 sorted(
1542 [re.escape(nick) for nick in participants.keys()],
1543 key=lambda nick: len(nick),
1544 reverse=True,
1545 )
1546 ),
1547 text,
1548 ):
1549 span = match.span()
1550 nick = match.group()
1551 if span[0] != 0 and text[span[0] - 1] not in _WHITESPACE_OR_PUNCTUATION:
1552 continue
1553 if span[1] == len(text) or text[span[1]] in _WHITESPACE_OR_PUNCTUATION:
1554 participant = self.participant_from_store(
1555 stored=participants[nick],
1556 )
1557 if contact := participant.contact:
1558 result.append(
1559 Mention(contact=contact, start=span[0], end=span[1])
1560 )
1561 return result
1563 async def on_set_subject(self, subject: str) -> None:
1564 """
1565 Triggered when the user requests changing the room subject.
1567 The legacy module is responsible for updating :attr:`.subject` of this
1568 instance.
1570 :param subject: The new subject for this room.
1571 """
1572 raise NotImplementedError
1574 async def on_set_thread_subject(
1575 self, thread: LegacyThreadType, subject: str
1576 ) -> None:
1577 """
1578 Triggered when the user requests changing the subject of a specific thread.
1580 :param thread: Legacy identifier of the thread
1581 :param subject: The new subject for this thread.
1582 """
1583 raise NotImplementedError
1585 @property
1586 def participants_filled(self) -> bool:
1587 return self.stored.participants_filled
1589 def get_archived_messages(
1590 self, msg_id: LegacyMessageType | str
1591 ) -> Iterator[HistoryMessage]:
1592 """
1593 Query the slidge archive for messages sent in this group
1595 :param msg_id: Message ID of the message in question. Can be either a legacy ID
1596 or an XMPP ID.
1597 :return: Iterator over messages. A single legacy ID can map to several messages,
1598 because of multi-attachment messages.
1599 """
1600 with self.xmpp.store.session() as orm:
1601 for stored in self.xmpp.store.mam.get_messages(
1602 orm, self.stored.id, ids=[str(msg_id)]
1603 ):
1604 yield HistoryMessage(stored.stanza)
1607def set_origin_id(msg: Message, origin_id: str) -> None:
1608 sub = ET.Element("{urn:xmpp:sid:0}origin-id")
1609 sub.attrib["id"] = origin_id
1610 msg.xml.append(sub)
1613def int_or_none(x: str) -> int | None:
1614 try:
1615 return int(x)
1616 except ValueError:
1617 return None
1620def equals_zero(x: int | None) -> bool:
1621 if x is None:
1622 return False
1623 else:
1624 return x == 0
1627def str_to_datetime_or_none(date: str | None) -> datetime | None:
1628 if date is None:
1629 return None
1630 try:
1631 return str_to_datetime(date)
1632 except ValueError:
1633 return None
1636def bookmarks_form() -> Form:
1637 form = Form()
1638 form["type"] = "submit"
1639 form.add_field(
1640 "FORM_TYPE",
1641 value="http://jabber.org/protocol/pubsub#publish-options",
1642 ftype="hidden",
1643 )
1644 form.add_field("pubsub#persist_items", value="1")
1645 form.add_field("pubsub#max_items", value="max")
1646 form.add_field("pubsub#send_last_published_item", value="never")
1647 form.add_field("pubsub#access_model", value="whitelist")
1648 return form
1651_BOOKMARKS_OPTIONS = bookmarks_form()
1652_WHITESPACE_OR_PUNCTUATION = string.whitespace + "!\"'(),.:;?@_"
1654log = logging.getLogger(__name__)