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