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