Coverage for slidge / core / session.py: 85%
262 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 05:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 05:07 +0000
1import abc
2import asyncio
3import logging
4from asyncio.tasks import Task
5from collections.abc import Coroutine, Iterable
6from typing import (
7 TYPE_CHECKING,
8 Any,
9 Generic,
10 NamedTuple,
11 Optional,
12 Union,
13 cast,
14)
16import aiohttp
17import sqlalchemy as sa
18from slixmpp import JID, Iq, Message, Presence
19from slixmpp.exceptions import XMPPError
20from slixmpp.types import PresenceShows, ResourceDict
22from slidge.db.meta import JSONSerializable
24from ..command import SearchResult
25from ..contact import LegacyContact
26from ..db.models import Contact, GatewayUser
27from ..group.room import LegacyMUC
28from ..util import SubclassableOnce
29from ..util.lock import NamedLockMixin
30from ..util.types import (
31 AnyBookmarks,
32 AnyContact,
33 AnyMUC,
34 AnyRoster,
35 AnySession,
36 LegacyGroupIdType,
37 LegacyMessageType,
38 LegacyThreadType,
39 LinkPreview,
40 Mention,
41 PseudoPresenceShow,
42 RecipientType,
43 Sticker,
44)
45from ..util.util import deprecated, noop_coro
47if TYPE_CHECKING:
48 from ..group.participant import LegacyParticipant
49 from ..util.types import Sender
50 from .gateway import BaseGateway
53class CachedPresence(NamedTuple):
54 status: str | None
55 show: str | None
56 kwargs: dict[str, Any]
59class BaseSession(
60 Generic[LegacyMessageType, RecipientType],
61 NamedLockMixin,
62 SubclassableOnce,
63 abc.ABC,
64):
65 """
66 The session of a registered :term:`User`.
68 Represents a gateway user logged in to the legacy network and performing actions.
70 Will be instantiated automatically on slidge startup for each registered user,
71 or upon registration for new (validated) users.
73 Must be subclassed for a functional :term:`Legacy Module`.
74 """
76 """
77 Since we cannot set the XMPP ID of messages sent by XMPP clients, we need to keep a mapping
78 between XMPP IDs and legacy message IDs if we want to further refer to a message that was sent
79 by the user. This also applies to 'carboned' messages, ie, messages sent by the user from
80 the official client of a legacy network.
81 """
83 xmpp: "BaseGateway"
84 """
85 The gateway instance singleton. Use it for low-level XMPP calls or custom methods that are not
86 session-specific.
87 """
89 MESSAGE_IDS_ARE_THREAD_IDS = False
90 """
91 Set this to True if the legacy service uses message IDs as thread IDs,
92 eg Mattermost, where you can only 'create a thread' by replying to the message,
93 in which case the message ID is also a thread ID (and all messages are potential
94 threads).
95 """
96 SPECIAL_MSG_ID_PREFIX: str | None = None
97 """
98 If you set this, XMPP message IDs starting with this won't be converted to legacy ID,
99 but passed as is to :meth:`.on_react`, and usual checks for emoji restriction won't be
100 applied.
101 This can be used to implement voting in polls in a hacky way.
102 """
104 _roster_cls: type[AnyRoster]
105 _bookmarks_cls: type[AnyBookmarks]
107 def __init__(self, user: GatewayUser) -> None:
108 super().__init__()
109 self.user = user
110 self.log = logging.getLogger(user.jid.bare)
112 self.ignore_messages = set[str]()
114 self.contacts: AnyRoster = self._roster_cls(self)
115 self.is_logging_in = False
116 self._logged = False
117 self.__reset_ready()
119 self.bookmarks = self._bookmarks_cls(self)
121 self.thread_creation_lock = asyncio.Lock()
123 self.__cached_presence: CachedPresence | None = None
125 self.__tasks = set[asyncio.Task[Any]]()
127 @property
128 def user_jid(self) -> JID:
129 return self.user.jid
131 @property
132 def user_pk(self) -> int:
133 return self.user.id
135 @property
136 def http(self) -> aiohttp.ClientSession:
137 return self.xmpp.http
139 def __remove_task(self, fut: Task[Any]) -> None:
140 self.log.debug("Removing fut %s", fut)
141 self.__tasks.remove(fut)
143 def create_task(
144 self, coro: Coroutine[Any, Any, Any], name: str | None = None
145 ) -> asyncio.Task[Any]:
146 task = self.xmpp.loop.create_task(coro, name=name)
147 self.__tasks.add(task)
148 self.log.debug("Creating task %s", task)
149 task.add_done_callback(lambda _: self.__remove_task(task))
150 return task
152 def cancel_all_tasks(self) -> None:
153 for task in self.__tasks:
154 task.cancel()
156 @abc.abstractmethod
157 async def login(self) -> str | None:
158 """
159 Logs in the gateway user to the legacy network.
161 Triggered when the gateway start and on user registration.
162 It is recommended that this function returns once the user is logged in,
163 so if you need to await forever (for instance to listen to incoming events),
164 it's a good idea to wrap your listener in an asyncio.Task.
166 :return: Optionally, a text to use as the gateway status, e.g., "Connected as 'dude@legacy.network'"
167 """
168 raise NotImplementedError
170 async def logout(self) -> None:
171 """
172 Logs out the gateway user from the legacy network.
174 Called on gateway shutdown.
175 """
176 raise NotImplementedError
178 async def on_text(
179 self,
180 chat: RecipientType,
181 text: str,
182 *,
183 reply_to_msg_id: LegacyMessageType | None = None,
184 reply_to_fallback_text: str | None = None,
185 reply_to: Optional["Sender"] = None,
186 thread: LegacyThreadType | None = None,
187 link_previews: Iterable[LinkPreview] = (),
188 mentions: list[Mention] | None = None,
189 ) -> LegacyMessageType | None:
190 """
191 Triggered when the user sends a text message from XMPP to a bridged entity, e.g.
192 to ``translated_user_name@slidge.example.com``, or ``translated_group_name@slidge.example.com``
194 Override this and implement sending a message to the legacy network in this method.
196 :param text: Content of the message
197 :param chat: Recipient of the message. :class:`.LegacyContact` instance for 1:1 chat,
198 :class:`.MUC` instance for groups.
199 :param reply_to_msg_id: A legacy message ID if the message references (quotes)
200 another message (:xep:`0461`)
201 :param reply_to_fallback_text: Content of the quoted text. Not necessarily set
202 by XMPP clients
203 :param reply_to: Author of the quoted message. :class:`LegacyContact` instance for
204 1:1 chat, :class:`LegacyParticipant` instance for groups.
205 If `None`, should be interpreted as a self-reply if reply_to_msg_id is not None.
206 :param link_previews: A list of sender-generated link previews.
207 At the time of writing, only `Cheogram <https://wiki.soprani.ca/CheogramApp/LinkPreviews>`_
208 supports it.
209 :param mentions: (only for groups) A list of Contacts mentioned by their
210 nicknames.
211 :param thread:
213 :return: An ID of some sort that can be used later to ack and mark the message
214 as read by the user
215 """
216 raise NotImplementedError
218 send_text = deprecated("BaseSession.send_text", on_text)
220 async def on_file(
221 self,
222 chat: RecipientType,
223 url: str,
224 *,
225 http_response: aiohttp.ClientResponse,
226 reply_to_msg_id: LegacyMessageType | None = None,
227 reply_to_fallback_text: str | None = None,
228 reply_to: Union["AnyContact", "LegacyParticipant"] | None = None,
229 thread: LegacyThreadType | None = None,
230 ) -> LegacyMessageType | None:
231 """
232 Triggered when the user sends a file using HTTP Upload (:xep:`0363`)
234 :param url: URL of the file
235 :param chat: See :meth:`.BaseSession.on_text`
236 :param http_response: The HTTP GET response object on the URL
237 :param reply_to_msg_id: See :meth:`.BaseSession.on_text`
238 :param reply_to_fallback_text: See :meth:`.BaseSession.on_text`
239 :param reply_to: See :meth:`.BaseSession.on_text`
240 :param thread:
242 :return: An ID of some sort that can be used later to ack and mark the message
243 as read by the user
244 """
245 raise NotImplementedError
247 send_file = deprecated("BaseSession.send_file", on_file)
249 async def on_sticker(
250 self,
251 chat: RecipientType,
252 sticker: Sticker,
253 *,
254 reply_to_msg_id: LegacyMessageType | None = None,
255 reply_to_fallback_text: str | None = None,
256 reply_to: Union["AnyContact", "LegacyParticipant"] | None = None,
257 thread: LegacyThreadType | None = None,
258 ) -> LegacyMessageType | None:
259 """
260 Triggered when the user sends a file using HTTP Upload (:xep:`0363`)
262 :param chat: See :meth:`.BaseSession.on_text`
263 :param sticker: The sticker sent by the user.
264 :param reply_to_msg_id: See :meth:`.BaseSession.on_text`
265 :param reply_to_fallback_text: See :meth:`.BaseSession.on_text`
266 :param reply_to: See :meth:`.BaseSession.on_text`
267 :param thread:
269 :return: An ID of some sort that can be used later to ack and mark the message
270 as read by the user
271 """
272 raise NotImplementedError
274 async def on_active(
275 self, chat: RecipientType, thread: LegacyThreadType | None = None
276 ) -> None:
277 """
278 Triggered when the user sends an 'active' chat state (:xep:`0085`)
280 :param chat: See :meth:`.BaseSession.on_text`
281 :param thread:
282 """
283 raise NotImplementedError
285 active = deprecated("BaseSession.active", on_active)
287 async def on_inactive(
288 self, chat: RecipientType, thread: LegacyThreadType | None = None
289 ) -> None:
290 """
291 Triggered when the user sends an 'inactive' chat state (:xep:`0085`)
293 :param chat: See :meth:`.BaseSession.on_text`
294 :param thread:
295 """
296 raise NotImplementedError
298 inactive = deprecated("BaseSession.inactive", on_inactive)
300 async def on_composing(
301 self, chat: RecipientType, thread: LegacyThreadType | None = None
302 ) -> None:
303 """
304 Triggered when the user starts typing in a legacy chat (:xep:`0085`)
306 :param chat: See :meth:`.BaseSession.on_text`
307 :param thread:
308 """
309 raise NotImplementedError
311 composing = deprecated("BaseSession.composing", on_composing)
313 async def on_paused(
314 self, chat: RecipientType, thread: LegacyThreadType | None = None
315 ) -> None:
316 """
317 Triggered when the user pauses typing in a legacy chat (:xep:`0085`)
319 :param chat: See :meth:`.BaseSession.on_text`
320 :param thread:
321 """
322 raise NotImplementedError
324 paused = deprecated("BaseSession.paused", on_paused)
326 async def on_gone(
327 self, chat: RecipientType, thread: LegacyThreadType | None = None
328 ) -> None:
329 """
330 Triggered when the user is "gone" in a legacy chat (:xep:`0085`)
332 :param chat: See :meth:`.BaseSession.on_text`
333 :param thread:
334 """
335 raise NotImplementedError
337 async def on_displayed(
338 self,
339 chat: RecipientType,
340 legacy_msg_id: LegacyMessageType,
341 thread: LegacyThreadType | None = None,
342 ) -> None:
343 """
344 Triggered when the user reads a message in a legacy chat. (:xep:`0333`)
346 This is only possible if a valid ``legacy_msg_id`` was passed when
347 transmitting a message from a legacy chat to the user, eg in
348 :meth:`slidge.contact.LegacyContact.send_text`
349 or
350 :meth:`slidge.group.LegacyParticipant.send_text`.
352 :param chat: See :meth:`.BaseSession.on_text`
353 :param legacy_msg_id: Identifier of the message/
354 :param thread:
355 """
356 raise NotImplementedError
358 displayed = deprecated("BaseSession.displayed", on_displayed)
360 async def on_correct(
361 self,
362 chat: RecipientType,
363 text: str,
364 legacy_msg_id: LegacyMessageType,
365 *,
366 thread: LegacyThreadType | None = None,
367 link_previews: Iterable[LinkPreview] = (),
368 mentions: list[Mention] | None = None,
369 ) -> LegacyMessageType | None:
370 """
371 Triggered when the user corrects a message using :xep:`0308`
373 This is only possible if a valid ``legacy_msg_id`` was returned by
374 :meth:`.on_text`.
376 :param chat: See :meth:`.BaseSession.on_text`
377 :param text: The new text
378 :param legacy_msg_id: Identifier of the edited message
379 :param thread:
380 :param link_previews: A list of sender-generated link previews.
381 At the time of writing, only `Cheogram <https://wiki.soprani.ca/CheogramApp/LinkPreviews>`_
382 supports it.
383 :param mentions: (only for groups) A list of Contacts mentioned by their
384 nicknames.
385 """
386 raise NotImplementedError
388 correct = deprecated("BaseSession.correct", on_correct)
390 async def on_react(
391 self,
392 chat: RecipientType,
393 legacy_msg_id: LegacyMessageType,
394 emojis: list[str],
395 thread: LegacyThreadType | None = None,
396 ) -> None:
397 """
398 Triggered when the user sends message reactions (:xep:`0444`).
400 :param chat: See :meth:`.BaseSession.on_text`
401 :param thread:
402 :param legacy_msg_id: ID of the message the user reacts to
403 :param emojis: Unicode characters representing reactions to the message ``legacy_msg_id``.
404 An empty string means "no reaction", ie, remove all reactions if any were present before
405 """
406 raise NotImplementedError
408 react = deprecated("BaseSession.react", on_react)
410 async def on_retract(
411 self,
412 chat: RecipientType,
413 legacy_msg_id: LegacyMessageType,
414 thread: LegacyThreadType | None = None,
415 ) -> None:
416 """
417 Triggered when the user retracts (:xep:`0424`) a message.
419 :param chat: See :meth:`.BaseSession.on_text`
420 :param thread:
421 :param legacy_msg_id: Legacy ID of the retracted message
422 """
423 raise NotImplementedError
425 retract = deprecated("BaseSession.retract", on_retract)
427 async def on_presence(
428 self,
429 resource: str,
430 show: PseudoPresenceShow,
431 status: str,
432 resources: dict[str, ResourceDict],
433 merged_resource: ResourceDict | None,
434 ) -> None:
435 """
436 Called when the gateway component receives a presence, ie, when
437 one of the user's clients goes online of offline, or changes its
438 status.
440 :param resource: The XMPP client identifier, arbitrary string.
441 :param show: The presence ``<show>``, if available. If the resource is
442 just 'available' without any ``<show>`` element, this is an empty
443 str.
444 :param status: A status message, like a deeply profound quote, eg,
445 "Roses are red, violets are blue, [INSERT JOKE]".
446 :param resources: A summary of all the resources for this user.
447 :param merged_resource: A global presence for the user account,
448 following rules described in :meth:`merge_resources`
449 """
450 raise NotImplementedError
452 presence = deprecated("BaseSession.presence", on_presence)
454 async def on_search(self, form_values: dict[str, str]) -> SearchResult | None:
455 """
456 Triggered when the user uses Jabber Search (:xep:`0055`) on the component
458 Form values is a dict in which keys are defined in :attr:`.BaseGateway.SEARCH_FIELDS`
460 :param form_values: search query, defined for a specific plugin by overriding
461 in :attr:`.BaseGateway.SEARCH_FIELDS`
462 :return:
463 """
464 raise NotImplementedError
466 search = deprecated("BaseSession.search", on_search)
468 async def on_avatar(
469 self,
470 bytes_: bytes | None,
471 hash_: str | None,
472 type_: str | None,
473 width: int | None,
474 height: int | None,
475 ) -> None:
476 """
477 Triggered when the user uses modifies their avatar via :xep:`0084`.
479 :param bytes_: The data of the avatar. According to the spec, this
480 should always be a PNG, but some implementations do not respect
481 that. If `None` it means the user has unpublished their avatar.
482 :param hash_: The SHA1 hash of the avatar data. This is an identifier of
483 the avatar.
484 :param type_: The MIME type of the avatar.
485 :param width: The width of the avatar image.
486 :param height: The height of the avatar image.
487 """
488 raise NotImplementedError
490 async def on_moderate(
491 self, muc: AnyMUC, legacy_msg_id: LegacyMessageType, reason: str | None
492 ) -> None:
493 """
494 Triggered when the user attempts to retract a message that was sent in
495 a MUC using :xep:`0425`.
497 If retraction is not possible, this should raise the appropriate
498 XMPPError with a human-readable message.
500 NB: the legacy module is responsible for calling
501 :method:`LegacyParticipant.moderate` when this is successful, because
502 slidge will acknowledge the moderation IQ, but will not send the
503 moderation message from the MUC automatically.
505 :param muc: The MUC in which the message was sent
506 :param legacy_msg_id: The legacy ID of the message to be retracted
507 :param reason: Optionally, a reason for the moderation, given by the
508 user-moderator.
509 """
510 raise NotImplementedError
512 async def on_create_group(
513 self, name: str, contacts: list[AnyContact]
514 ) -> LegacyGroupIdType:
515 """
516 Triggered when the user request the creation of a group via the
517 dedicated :term:`Command`.
519 :param name: Name of the group
520 :param contacts: list of contacts that should be members of the group
521 """
522 raise NotImplementedError
524 async def on_invitation(
525 self, contact: AnyContact, muc: AnyMUC, reason: str | None
526 ) -> None:
527 """
528 Triggered when the user invites a :term:`Contact` to a legacy MUC via
529 :xep:`0249`.
531 The default implementation calls :meth:`LegacyMUC.on_set_affiliation`
532 with the 'member' affiliation. Override if you want to customize this
533 behaviour.
535 :param contact: The invitee
536 :param muc: The group
537 :param reason: Optionally, a reason
538 """
539 await muc.on_set_affiliation(contact, "member", reason, None)
541 async def on_leave_group(self, muc_legacy_id: LegacyGroupIdType) -> None:
542 """
543 Triggered when the user leaves a group via the dedicated slidge command
544 or the :xep:`0077` ``<remove />`` mechanism.
546 This should be interpreted as definitely leaving the group.
548 :param muc_legacy_id: The legacy ID of the group to leave
549 """
550 raise NotImplementedError
552 async def on_preferences(
553 self, previous: dict[str, Any], new: dict[str, Any]
554 ) -> None:
555 """
556 This is called when the user updates their preferences.
558 Override this if you need set custom preferences field and need to trigger
559 something when a preference has changed.
560 """
561 raise NotImplementedError
563 def __reset_ready(self) -> None:
564 self.ready = self.xmpp.loop.create_future()
566 @property
567 def logged(self) -> bool:
568 return self._logged
570 @logged.setter
571 def logged(self, v: bool) -> None:
572 self.is_logging_in = False
573 self._logged = v
574 if self.ready.done():
575 if v:
576 return
577 self.__reset_ready()
578 self.shutdown(logout=False)
579 with self.xmpp.store.session() as orm:
580 self.xmpp.store.mam.reset_source(orm)
581 self.xmpp.store.rooms.reset_updated(orm)
582 self.xmpp.store.contacts.reset_updated(orm)
583 orm.commit()
584 else:
585 if v:
586 self.ready.set_result(True)
588 def __repr__(self) -> str:
589 return f"<Session of {self.user_jid}>"
591 def shutdown(self, logout: bool = True) -> asyncio.Task[None]:
592 for m in self.bookmarks:
593 m.shutdown()
594 with self.xmpp.store.session() as orm:
595 for jid in orm.execute(
596 sa.select(Contact.jid).filter_by(user=self.user, is_friend=True)
597 ).scalars():
598 pres = self.xmpp.make_presence(
599 pfrom=jid,
600 pto=self.user_jid,
601 ptype="unavailable",
602 pstatus="Gateway has shut down.",
603 )
604 pres.send()
605 if logout:
606 return self.xmpp.loop.create_task(self.__logout())
607 else:
608 return self.xmpp.loop.create_task(noop_coro())
610 async def __logout(self) -> None:
611 try:
612 await self.logout()
613 except NotImplementedError:
614 pass
615 except KeyboardInterrupt:
616 pass
618 @staticmethod
619 def legacy_to_xmpp_msg_id(legacy_msg_id: LegacyMessageType) -> str:
620 """
621 Convert a legacy msg ID to a valid XMPP msg ID.
622 Needed for read marks, retractions and message corrections.
624 The default implementation just converts the legacy ID to a :class:`str`,
625 but this should be overridden in case some characters needs to be escaped,
626 or to add some additional,
627 :term:`legacy network <Legacy Network`>-specific logic.
629 :param legacy_msg_id:
630 :return: A string that is usable as an XMPP stanza ID
631 """
632 return str(legacy_msg_id)
634 legacy_msg_id_to_xmpp_msg_id = staticmethod(
635 deprecated("BaseSession.legacy_msg_id_to_xmpp_msg_id", legacy_to_xmpp_msg_id)
636 )
638 @staticmethod
639 def xmpp_to_legacy_msg_id(i: str) -> LegacyMessageType:
640 """
641 Convert a legacy XMPP ID to a valid XMPP msg ID.
642 Needed for read marks and message corrections.
644 The default implementation just converts the legacy ID to a :class:`str`,
645 but this should be overridden in case some characters needs to be escaped,
646 or to add some additional,
647 :term:`legacy network <Legacy Network`>-specific logic.
649 The default implementation is an identity function.
651 :param i: The XMPP stanza ID
652 :return: An ID that can be used to identify a message on the legacy network
653 """
654 return cast(LegacyMessageType, i)
656 xmpp_msg_id_to_legacy_msg_id = staticmethod(
657 deprecated("BaseSession.xmpp_msg_id_to_legacy_msg_id", xmpp_to_legacy_msg_id)
658 )
660 def raise_if_not_logged(self) -> None:
661 if not self.logged:
662 raise XMPPError(
663 "internal-server-error",
664 text="You are not logged to the legacy network",
665 )
667 @classmethod
668 def _from_user_or_none(cls, user: GatewayUser | None) -> AnySession:
669 if user is None:
670 log.debug("user not found")
671 raise XMPPError(text="User not found", condition="subscription-required")
673 session = _sessions.get(user.jid.bare)
674 if session is None:
675 _sessions[user.jid.bare] = session = cls(user)
676 return session
678 @classmethod
679 def from_user(cls, user: GatewayUser) -> "AnySession":
680 return cls._from_user_or_none(user)
682 @classmethod
683 def from_stanza(cls, s: Message | Iq | Presence) -> "AnySession":
684 # """
685 # Get a user's :class:`.LegacySession` using the "from" field of a stanza
686 #
687 # Meant to be called from :class:`BaseGateway` only.
688 #
689 # :param s:
690 # :return:
691 # """
692 return cls.from_jid(s.get_from())
694 @classmethod
695 def from_jid(cls, jid: JID) -> AnySession:
696 # """
697 # Get a user's :class:`.LegacySession` using its jid
698 #
699 # Meant to be called from :class:`BaseGateway` only.
700 #
701 # :param jid:
702 # :return:
703 # """
704 session = _sessions.get(jid.bare)
705 if session is not None:
706 return session
707 with cls.xmpp.store.session() as orm:
708 user = orm.query(GatewayUser).filter_by(jid=jid.bare).one_or_none()
709 return cls._from_user_or_none(user)
711 @classmethod
712 async def kill_by_jid(cls, jid: JID) -> None:
713 # """
714 # Terminate a user session.
715 #
716 # Meant to be called from :class:`BaseGateway` only.
717 #
718 # :param jid:
719 # :return:
720 # """
721 log.debug("Killing session of %s", jid)
722 for user_jid, session in _sessions.items():
723 if user_jid == jid.bare:
724 break
725 else:
726 log.debug("Did not find a session for %s", jid)
727 return
728 for c in session.contacts:
729 c.unsubscribe()
730 for m in session.bookmarks:
731 m.shutdown()
733 try:
734 session = _sessions.pop(jid.bare)
735 except KeyError:
736 log.warning("User not found during unregistration")
737 return
739 session.cancel_all_tasks()
741 await cls.xmpp.unregister(session)
742 with cls.xmpp.store.session() as orm:
743 orm.delete(session.user)
744 orm.commit()
746 def __ack(self, msg: Message) -> None:
747 if not self.xmpp.PROPER_RECEIPTS:
748 self.xmpp.delivery_receipt.ack(msg)
750 def send_gateway_status(
751 self,
752 status: str | None = None,
753 show: PresenceShows | None = None,
754 **kwargs: Any, # noqa
755 ) -> None:
756 """
757 Send a presence from the gateway to the user.
759 Can be used to indicate the user session status, ie "SMS code required", "connected", …
761 :param status: A status message
762 :param show: Presence stanza 'show' element. I suggest using "dnd" to show
763 that the gateway is not fully functional
764 """
765 self.__cached_presence = CachedPresence(status, show, kwargs)
766 self.xmpp.send_presence(
767 pto=self.user_jid.bare, pstatus=status, pshow=show, **kwargs
768 )
770 def send_cached_presence(self, to: JID) -> None:
771 if not self.__cached_presence:
772 self.xmpp.send_presence(pto=to, ptype="unavailable")
773 return
774 self.xmpp.send_presence(
775 pto=to,
776 pstatus=self.__cached_presence.status,
777 pshow=self.__cached_presence.show,
778 **self.__cached_presence.kwargs,
779 )
781 def send_gateway_message(
782 self,
783 text: str,
784 **msg_kwargs: Any, # noqa
785 ) -> None:
786 """
787 Send a message from the gateway component to the user.
789 Can be used to indicate the user session status, ie "SMS code required", "connected", …
791 :param text: A text
792 """
793 self.xmpp.send_text(text, mto=self.user_jid, **msg_kwargs)
795 def send_gateway_invite(
796 self,
797 muc: AnyMUC,
798 reason: str | None = None,
799 password: str | None = None,
800 ) -> None:
801 """
802 Send an invitation to join a MUC, emanating from the gateway component.
804 :param muc:
805 :param reason:
806 :param password:
807 """
808 self.xmpp.invite_to(muc, reason=reason, password=password, mto=self.user_jid)
810 async def input(self, text: str, **msg_kwargs: Any) -> str: # noqa
811 """
812 Request user input via direct messages from the gateway component.
814 Wraps call to :meth:`.BaseSession.input`
816 :param text: The prompt to send to the user
817 :param msg_kwargs: Extra attributes
818 :return:
819 """
820 return await self.xmpp.input(self.user_jid, text, **msg_kwargs)
822 async def send_qr(self, text: str) -> None:
823 """
824 Sends a QR code generated from 'text' via HTTP Upload and send the URL to
825 ``self.user``
827 :param text: Text to encode as a QR code
828 """
829 await self.xmpp.send_qr(text, mto=self.user_jid)
831 async def get_contact_or_group_or_participant(
832 self, jid: JID, create: bool = True
833 ) -> (
834 LegacyContact[Any] | LegacyMUC[Any, Any, Any, Any] | "LegacyParticipant" | None
835 ):
836 if (contact := self.contacts.by_jid_only_if_exists(jid)) is not None:
837 return contact # type:ignore[no-any-return]
838 if (muc := self.bookmarks.by_jid_only_if_exists(JID(jid.bare))) is not None:
839 return await self.__get_muc_or_participant(muc, jid)
840 else:
841 muc = None
843 if not create:
844 return None
846 try:
847 return await self.contacts.by_jid(jid) # type:ignore[no-any-return]
848 except XMPPError:
849 if muc is None:
850 try:
851 muc = await self.bookmarks.by_jid(jid)
852 except XMPPError:
853 return None
854 return await self.__get_muc_or_participant(muc, jid)
856 @staticmethod
857 async def __get_muc_or_participant(
858 muc: AnyMUC, jid: JID
859 ) -> "AnyMUC | LegacyParticipant | None":
860 if nick := jid.resource:
861 return await muc.get_participant(nick, create=False, fill_first=True)
862 return muc
864 async def wait_for_ready(self, timeout: int | float | None = 10) -> None:
865 # """
866 # Wait until session, contacts and bookmarks are ready
867 #
868 # (slidge internal use)
869 #
870 # :param timeout:
871 # :return:
872 # """
873 try:
874 await asyncio.wait_for(asyncio.shield(self.ready), timeout)
875 await asyncio.wait_for(asyncio.shield(self.contacts.ready), timeout)
876 await asyncio.wait_for(asyncio.shield(self.bookmarks.ready), timeout)
877 except TimeoutError:
878 raise XMPPError(
879 "recipient-unavailable",
880 "Legacy session is not fully initialized, retry later",
881 )
883 def legacy_module_data_update(self, data: JSONSerializable) -> None:
884 user = self.user
885 user.legacy_module_data.update(data)
886 self.xmpp.store.users.update(user)
888 def legacy_module_data_set(self, data: JSONSerializable) -> None:
889 user = self.user
890 user.legacy_module_data = data
891 self.xmpp.store.users.update(user)
893 def legacy_module_data_clear(self) -> None:
894 user = self.user
895 user.legacy_module_data.clear()
896 self.xmpp.store.users.update(user)
899# keys = user.jid.bare
900_sessions: dict[str, AnySession] = {}
901log = logging.getLogger(__name__)