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