Coverage for slidge/core/gateway.py: 64%
423 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-04 08:17 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-04 08:17 +0000
1"""
2This module extends slixmpp.ComponentXMPP to make writing new LegacyClients easier
3"""
5import asyncio
6import logging
7import re
8import tempfile
9from copy import copy
10from datetime import datetime
11from pathlib import Path
12from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence, Type, Union, cast
14import aiohttp
15import qrcode
16from slixmpp import JID, ComponentXMPP, Iq, Message, Presence
17from slixmpp.exceptions import IqError, IqTimeout, XMPPError
18from slixmpp.plugins.xep_0004 import Form
19from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation
20from slixmpp.types import MessageTypes
21from slixmpp.xmlstream.xmlstream import NotConnectedError
23from slidge import LegacyContact, command # noqa: F401
24from slidge.command.adhoc import AdhocProvider
25from slidge.command.admin import Exec
26from slidge.command.base import Command, FormField
27from slidge.command.chat_command import ChatCommandProvider
28from slidge.command.register import RegistrationType
29from slidge.core import config
30from slidge.core.dispatcher.session_dispatcher import SessionDispatcher
31from slidge.core.mixins import MessageMixin
32from slidge.core.mixins.avatar import convert_avatar
33from slidge.core.pubsub import PubSubComponent
34from slidge.core.session import BaseSession
35from slidge.db import GatewayUser, SlidgeStore
36from slidge.db.avatar import CachedAvatar, avatar_cache
37from slidge.db.meta import JSONSerializable
38from slidge.slixfix import PrivilegedIqError
39from slidge.slixfix.delivery_receipt import DeliveryReceipt
40from slidge.slixfix.roster import RosterBackend
41from slidge.util import ABCSubclassableOnceAtMost
42from slidge.util.types import Avatar, MessageOrPresenceTypeVar
43from slidge.util.util import timeit
45if TYPE_CHECKING:
46 pass
49class BaseGateway(
50 ComponentXMPP,
51 MessageMixin,
52 metaclass=ABCSubclassableOnceAtMost,
53):
54 """
55 The gateway component, handling registrations and un-registrations.
57 On slidge launch, a singleton is instantiated, and it will be made available
58 to public classes such :class:`.LegacyContact` or :class:`.BaseSession` as the
59 ``.xmpp`` attribute.
61 Must be subclassed by a legacy module to set up various aspects of the XMPP
62 component behaviour, such as its display name or welcome message, via
63 class attributes :attr:`.COMPONENT_NAME` :attr:`.WELCOME_MESSAGE`.
65 Abstract methods related to the registration process must be overriden
66 for a functional :term:`Legacy Module`:
68 - :meth:`.validate`
69 - :meth:`.validate_two_factor_code`
70 - :meth:`.get_qr_text`
71 - :meth:`.confirm_qr`
73 NB: Not all of these must be overridden, it depends on the
74 :attr:`REGISTRATION_TYPE`.
76 The other methods, such as :meth:`.send_text` or :meth:`.react` are the same
77 as those of :class:`.LegacyContact` and :class:`.LegacyParticipant`, because
78 the component itself is also a "messaging actor", ie, an :term:`XMPP Entity`.
79 For these methods, you need to specify the JID of the recipient with the
80 `mto` parameter.
82 Since it inherits from :class:`slixmpp.componentxmpp.ComponentXMPP`,you also
83 have a hand on low-level XMPP interactions via slixmpp methods, e.g.:
85 .. code-block:: python
87 self.send_presence(
88 pfrom="somebody@component.example.com",
89 pto="someonwelse@anotherexample.com",
90 )
92 However, you should not need to do so often since the classes of the plugin
93 API provides higher level abstractions around most commonly needed use-cases, such
94 as sending messages, or displaying a custom status.
96 """
98 COMPONENT_NAME: str = NotImplemented
99 """Name of the component, as seen in service discovery by XMPP clients"""
100 COMPONENT_TYPE: str = ""
101 """Type of the gateway, should follow https://xmpp.org/registrar/disco-categories.html"""
102 COMPONENT_AVATAR: Optional[Avatar | Path | str] = None
103 """
104 Path, bytes or URL used by the component as an avatar.
105 """
107 REGISTRATION_FIELDS: Sequence[FormField] = [
108 FormField(var="username", label="User name", required=True),
109 FormField(var="password", label="Password", required=True, private=True),
110 ]
111 """
112 Iterable of fields presented to the gateway user when registering using :xep:`0077`
113 `extended <https://xmpp.org/extensions/xep-0077.html#extensibility>`_ by :xep:`0004`.
114 """
115 REGISTRATION_INSTRUCTIONS: str = "Enter your credentials"
116 """
117 The text presented to a user that wants to register (or modify) their legacy account
118 configuration.
119 """
120 REGISTRATION_TYPE: RegistrationType = RegistrationType.SINGLE_STEP_FORM
121 """
122 This attribute determines how users register to the gateway, ie, how they
123 login to the :term:`legacy service <Legacy Service>`.
124 The credentials are then stored persistently, so this process should happen
125 once per user (unless they unregister).
127 The registration process always start with a basic data form (:xep:`0004`)
128 presented to the user.
129 But the legacy login flow might require something more sophisticated, see
130 :class:`.RegistrationType` for more details.
131 """
133 REGISTRATION_2FA_TITLE = "Enter your 2FA code"
134 REGISTRATION_2FA_INSTRUCTIONS = (
135 "You should have received something via email or SMS, or something"
136 )
137 REGISTRATION_QR_INSTRUCTIONS = "Flash this code or follow this link"
139 PREFERENCES = [
140 FormField(
141 var="sync_presence",
142 label="Propagate your XMPP presence to the legacy network.",
143 value="true",
144 required=True,
145 type="boolean",
146 ),
147 FormField(
148 var="sync_avatar",
149 label="Propagate your XMPP avatar to the legacy network.",
150 value="true",
151 required=True,
152 type="boolean",
153 ),
154 FormField(
155 var="always_invite_when_adding_bookmarks",
156 label="Send an invitation to join MUCs after adding them to the bookmarks.",
157 value="true",
158 required=True,
159 type="boolean",
160 ),
161 FormField(
162 var="last_seen_fallback",
163 label="Use contact presence status message to show when they were last seen.",
164 value="true",
165 required=True,
166 type="boolean",
167 ),
168 FormField(
169 var="roster_push",
170 label="Add contacts to your roster.",
171 value="true",
172 required=True,
173 type="boolean",
174 ),
175 ]
177 ROSTER_GROUP: str = "slidge"
178 """
179 Name of the group assigned to a :class:`.LegacyContact` automagically
180 added to the :term:`User`'s roster with :meth:`.LegacyContact.add_to_roster`.
181 """
182 WELCOME_MESSAGE = (
183 "Thank you for registering. Type 'help' to list the available commands, "
184 "or just start messaging away!"
185 )
186 """
187 A welcome message displayed to users on registration.
188 This is useful notably for clients that don't consider component JIDs as a
189 valid recipient in their UI, yet still open a functional chat window on
190 incoming messages from components.
191 """
193 SEARCH_FIELDS: Sequence[FormField] = [
194 FormField(var="first", label="First name", required=True),
195 FormField(var="last", label="Last name", required=True),
196 FormField(var="phone", label="Phone number", required=False),
197 ]
198 """
199 Fields used for searching items via the component, through :xep:`0055` (jabber search).
200 A common use case is to allow users to search for legacy contacts by something else than
201 their usernames, eg their phone number.
203 Plugins should implement search by overriding :meth:`.BaseSession.search`
204 (restricted to registered users).
206 If there is only one field, it can also be used via the ``jabber:iq:gateway`` protocol
207 described in :xep:`0100`. Limitation: this only works if the search request returns
208 one result item, and if this item has a 'jid' var.
209 """
210 SEARCH_TITLE: str = "Search for legacy contacts"
211 """
212 Title of the search form.
213 """
214 SEARCH_INSTRUCTIONS: str = ""
215 """
216 Instructions of the search form.
217 """
219 MARK_ALL_MESSAGES = False
220 """
221 Set this to True for :term:`legacy networks <Legacy Network>` that expects
222 read marks for *all* messages and not just the latest one that was read
223 (as most XMPP clients will only send a read mark for the latest msg).
224 """
226 PROPER_RECEIPTS = False
227 """
228 Set this to True if the legacy service provides a real equivalent of message delivery receipts
229 (:xep:`0184`), meaning that there is an event thrown when the actual device of a contact receives
230 a message. Make sure to call Contact.received() adequately if this is set to True.
231 """
233 GROUPS = False
235 mtype: MessageTypes = "chat"
236 is_group = False
237 _can_send_carbon = False
238 store: SlidgeStore
240 AVATAR_ID_TYPE: Callable[[str], Any] = str
241 """
242 Modify this if the legacy network uses unique avatar IDs that are not strings.
244 This is required because we store those IDs as TEXT in the persistent SQL DB.
245 The callable specified here will receive is responsible for converting the
246 serialised-as-text version of the avatar unique ID back to the proper type.
247 Common example: ``int``.
248 """
249 # FIXME: do we really need this since we have session.xmpp_to_legacy_msg_id?
250 # (maybe we do)
251 LEGACY_MSG_ID_TYPE: Callable[[str], Any] = str
252 """
253 Modify this if the legacy network uses unique message IDs that are not strings.
255 This is required because we store those IDs as TEXT in the persistent SQL DB.
256 The callable specified here will receive is responsible for converting the
257 serialised-as-text version of the message unique ID back to the proper type.
258 Common example: ``int``.
259 """
260 LEGACY_CONTACT_ID_TYPE: Callable[[str], Any] = str
261 """
262 Modify this if the legacy network uses unique contact IDs that are not strings.
264 This is required because we store those IDs as TEXT in the persistent SQL DB.
265 The callable specified here is responsible for converting the
266 serialised-as-text version of the contact unique ID back to the proper type.
267 Common example: ``int``.
268 """
269 LEGACY_ROOM_ID_TYPE: Callable[[str], Any] = str
270 """
271 Modify this if the legacy network uses unique room IDs that are not strings.
273 This is required because we store those IDs as TEXT in the persistent SQL DB.
274 The callable specified here is responsible for converting the
275 serialised-as-text version of the room unique ID back to the proper type.
276 Common example: ``int``.
277 """
279 http: aiohttp.ClientSession
280 avatar: CachedAvatar | None = None
282 def __init__(self) -> None:
283 if config.COMPONENT_NAME:
284 self.COMPONENT_NAME = config.COMPONENT_NAME
285 if config.WELCOME_MESSAGE:
286 self.WELCOME_MESSAGE = config.WELCOME_MESSAGE
287 self.log = log
288 self.datetime_started = datetime.now()
289 self.xmpp = self # ugly hack to work with the BaseSender mixin :/
290 self.default_ns = "jabber:component:accept"
291 super().__init__(
292 config.JID,
293 config.SECRET,
294 config.SERVER,
295 config.PORT,
296 plugin_whitelist=SLIXMPP_PLUGINS,
297 plugin_config={
298 "xep_0077": {
299 "form_fields": None,
300 "form_instructions": self.REGISTRATION_INSTRUCTIONS,
301 "enable_subscription": self.REGISTRATION_TYPE
302 == RegistrationType.SINGLE_STEP_FORM,
303 },
304 "xep_0100": {
305 "component_name": self.COMPONENT_NAME,
306 "type": self.COMPONENT_TYPE,
307 },
308 "xep_0184": {
309 "auto_ack": False,
310 "auto_request": False,
311 },
312 "xep_0363": {
313 "upload_service": config.UPLOAD_SERVICE,
314 },
315 },
316 fix_error_ns=True,
317 )
318 self.loop.set_exception_handler(self.__exception_handler)
319 self.loop.create_task(self.__set_http())
320 self.has_crashed: bool = False
321 self.use_origin_id = False
323 self.jid_validator: re.Pattern = re.compile(config.USER_JID_VALIDATOR)
324 self.qr_pending_registrations = dict[str, asyncio.Future[Optional[dict]]]()
326 self.__setup_legacy_module_subclasses()
328 self.get_session_from_stanza: Callable[
329 [Union[Message, Presence, Iq]], BaseSession
330 ] = self._session_cls.from_stanza
331 self.get_session_from_user: Callable[[GatewayUser], BaseSession] = (
332 self._session_cls.from_user
333 )
335 self.register_plugins()
336 self.__register_slixmpp_events()
337 self.__register_slixmpp_api()
338 self.roster.set_backend(RosterBackend(self))
340 self.register_plugin("pubsub", {"component_name": self.COMPONENT_NAME})
341 self.pubsub: PubSubComponent = self["pubsub"]
342 self.delivery_receipt: DeliveryReceipt = DeliveryReceipt(self)
344 # with this we receive user avatar updates
345 self.plugin["xep_0030"].add_feature("urn:xmpp:avatar:metadata+notify")
347 self.plugin["xep_0030"].add_feature("urn:xmpp:chat-markers:0")
349 if self.GROUPS:
350 self.plugin["xep_0030"].add_feature("http://jabber.org/protocol/muc")
351 self.plugin["xep_0030"].add_feature("urn:xmpp:mam:2")
352 self.plugin["xep_0030"].add_feature("urn:xmpp:mam:2#extended")
353 self.plugin["xep_0030"].add_feature(self.plugin["xep_0421"].namespace)
354 self.plugin["xep_0030"].add_feature(self["xep_0317"].stanza.NS)
355 self.plugin["xep_0030"].add_identity(
356 category="conference",
357 name=self.COMPONENT_NAME,
358 itype="text",
359 jid=self.boundjid,
360 )
362 # why does mypy need these type annotations? no idea
363 self.__adhoc_handler: AdhocProvider = AdhocProvider(self)
364 self.__chat_commands_handler: ChatCommandProvider = ChatCommandProvider(self)
366 self.__dispatcher = SessionDispatcher(self)
368 self.__register_commands()
370 MessageMixin.__init__(self) # ComponentXMPP does not call super().__init__()
372 def __setup_legacy_module_subclasses(self):
373 from ..contact.roster import LegacyRoster
374 from ..group.bookmarks import LegacyBookmarks
375 from ..group.room import LegacyMUC, LegacyParticipant
377 session_cls: Type[BaseSession] = cast(
378 Type[BaseSession], BaseSession.get_unique_subclass()
379 )
380 contact_cls = LegacyContact.get_self_or_unique_subclass()
381 muc_cls = LegacyMUC.get_self_or_unique_subclass()
382 participant_cls = LegacyParticipant.get_self_or_unique_subclass()
383 bookmarks_cls = LegacyBookmarks.get_self_or_unique_subclass()
384 roster_cls = LegacyRoster.get_self_or_unique_subclass()
386 session_cls.xmpp = self # type:ignore[attr-defined]
387 contact_cls.xmpp = self # type:ignore[attr-defined]
388 muc_cls.xmpp = self # type:ignore[attr-defined]
390 self._session_cls = session_cls
391 session_cls._bookmarks_cls = bookmarks_cls # type:ignore[misc,assignment]
392 session_cls._roster_cls = roster_cls # type:ignore[misc,assignment]
393 LegacyRoster._contact_cls = contact_cls # type:ignore[misc]
394 LegacyBookmarks._muc_cls = muc_cls # type:ignore[misc]
395 LegacyMUC._participant_cls = participant_cls # type:ignore[misc]
397 async def kill_session(self, jid: JID) -> None:
398 await self._session_cls.kill_by_jid(jid)
400 async def __set_http(self) -> None:
401 self.http = aiohttp.ClientSession()
402 if getattr(self, "_test_mode", False):
403 return
404 avatar_cache.http = self.http
406 def __register_commands(self) -> None:
407 for cls in Command.subclasses:
408 if any(x is NotImplemented for x in [cls.CHAT_COMMAND, cls.NODE, cls.NAME]):
409 log.debug("Not adding command '%s' because it looks abstract", cls)
410 continue
411 if cls is Exec:
412 if config.DEV_MODE:
413 log.warning(r"/!\ DEV MODE ENABLED /!\\")
414 else:
415 continue
416 c = cls(self)
417 log.debug("Registering %s", cls)
418 self.__adhoc_handler.register(c)
419 self.__chat_commands_handler.register(c)
421 def __exception_handler(self, loop: asyncio.AbstractEventLoop, context) -> None:
422 """
423 Called when a task created by loop.create_task() raises an Exception
425 :param loop:
426 :param context:
427 :return:
428 """
429 log.debug("Context in the exception handler: %s", context)
430 exc = context.get("exception")
431 if exc is None:
432 log.debug("No exception in this context: %s", context)
433 elif isinstance(exc, SystemExit):
434 log.debug("SystemExit called in an asyncio task")
435 else:
436 log.error("Crash in an asyncio task: %s", context)
437 log.exception("Crash in task", exc_info=exc)
438 self.has_crashed = True
439 loop.stop()
441 def __register_slixmpp_events(self) -> None:
442 self.del_event_handler("presence_subscribe", self._handle_subscribe)
443 self.del_event_handler("presence_unsubscribe", self._handle_unsubscribe)
444 self.del_event_handler("presence_subscribed", self._handle_subscribed)
445 self.del_event_handler("presence_unsubscribed", self._handle_unsubscribed)
446 self.del_event_handler(
447 "roster_subscription_request", self._handle_new_subscription
448 )
449 self.del_event_handler("presence_probe", self._handle_probe)
450 self.add_event_handler("session_start", self.__on_session_start)
451 self.add_event_handler("disconnected", self.connect)
453 def __register_slixmpp_api(self) -> None:
454 def with_session(func, commit: bool = True):
455 def wrapped(*a, **kw):
456 with self.store.session() as orm:
457 res = func(orm, *a, **kw)
458 if commit:
459 orm.commit()
460 return res
462 return wrapped
464 self.plugin["xep_0231"].api.register(
465 with_session(self.store.bob.get_bob, False), "get_bob"
466 )
467 self.plugin["xep_0231"].api.register(
468 with_session(self.store.bob.set_bob), "set_bob"
469 )
470 self.plugin["xep_0231"].api.register(
471 with_session(self.store.bob.del_bob), "del_bob"
472 )
474 @property # type: ignore
475 def jid(self):
476 # Override to avoid slixmpp deprecation warnings.
477 return self.boundjid
479 async def __on_session_start(self, event) -> None:
480 log.debug("Gateway session start: %s", event)
482 # prevents XMPP clients from considering the gateway as an HTTP upload
483 disco = self.plugin["xep_0030"]
484 await disco.del_feature(feature="urn:xmpp:http:upload:0", jid=self.boundjid)
485 await self.plugin["xep_0115"].update_caps(jid=self.boundjid)
487 if self.COMPONENT_AVATAR is not None:
488 log.debug("Setting gateway avatar…")
489 avatar = convert_avatar(self.COMPONENT_AVATAR, "!!---slidge---special---")
490 assert avatar is not None
491 try:
492 cached_avatar = await avatar_cache.convert_or_get(avatar)
493 except Exception as e:
494 log.exception("Could not set the component avatar.", exc_info=e)
495 cached_avatar = None
496 else:
497 assert cached_avatar is not None
498 self.avatar = cached_avatar
499 else:
500 cached_avatar = None
502 with self.store.session() as orm:
503 for user in orm.query(GatewayUser).all():
504 # TODO: before this, we should check if the user has removed us from their roster
505 # while we were offline and trigger unregister from there. Presence probe does not seem
506 # to work in this case, there must be another way. privileged entity could be used
507 # as last resort.
508 try:
509 await self["xep_0100"].add_component_to_roster(user.jid)
510 await self.__add_component_to_mds_whitelist(user.jid)
511 except (IqError, IqTimeout) as e:
512 # TODO: remove the user when this happens? or at least
513 # this can happen when the user has unsubscribed from the XMPP server
514 log.warning(
515 "Error with user %s, not logging them automatically",
516 user,
517 exc_info=e,
518 )
519 continue
520 session = self._session_cls.from_user(user)
521 session.create_task(self.login_wrap(session))
522 if cached_avatar is not None:
523 await self.pubsub.broadcast_avatar(
524 self.boundjid.bare, session.user_jid, cached_avatar
525 )
527 log.info("Slidge has successfully started")
529 async def __add_component_to_mds_whitelist(self, user_jid: JID) -> None:
530 # Uses privileged entity to add ourselves to the whitelist of the PEP
531 # MDS node so we receive MDS events
532 iq_creation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set")
533 iq_creation["pubsub"]["create"]["node"] = self["xep_0490"].stanza.NS
535 try:
536 await self["xep_0356"].send_privileged_iq(iq_creation)
537 except PermissionError:
538 log.warning(
539 "IQ privileges not granted for pubsub namespace, we cannot "
540 "create the MDS node of %s",
541 user_jid,
542 )
543 except PrivilegedIqError as exc:
544 nested = exc.nested_error()
545 # conflict this means the node already exists, we can ignore that
546 if nested is not None and nested.condition != "conflict":
547 log.exception(
548 "Could not create the MDS node of %s", user_jid, exc_info=exc
549 )
550 except Exception as e:
551 log.exception(
552 "Error while trying to create to the MDS node of %s",
553 user_jid,
554 exc_info=e,
555 )
557 iq_affiliation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set")
558 iq_affiliation["pubsub_owner"]["affiliations"]["node"] = self[
559 "xep_0490"
560 ].stanza.NS
562 aff = OwnerAffiliation()
563 aff["jid"] = self.boundjid.bare
564 aff["affiliation"] = "member"
565 iq_affiliation["pubsub_owner"]["affiliations"].append(aff)
567 try:
568 await self["xep_0356"].send_privileged_iq(iq_affiliation)
569 except PermissionError:
570 log.warning(
571 "IQ privileges not granted for pubsub#owner namespace, we cannot "
572 "listen to the MDS events of %s",
573 user_jid,
574 )
575 except Exception as e:
576 log.exception(
577 "Error while trying to subscribe to the MDS node of %s",
578 user_jid,
579 exc_info=e,
580 )
582 @timeit
583 async def login_wrap(self, session: "BaseSession") -> None:
584 session.send_gateway_status("Logging in…", show="dnd")
585 session.is_logging_in = True
586 try:
587 status = await session.login()
588 except Exception as e:
589 log.warning("Login problem for %s", session.user_jid, exc_info=e)
590 log.exception(e)
591 session.send_gateway_status(f"Could not login: {e}", show="busy")
592 session.send_gateway_message(
593 "You are not connected to this gateway! "
594 f"Maybe this message will tell you why: {e}"
595 )
596 session.logged = False
597 return
599 log.info("Login success for %s", session.user_jid)
600 session.logged = True
601 session.send_gateway_status("Syncing contacts…", show="dnd")
602 with self.store.session() as orm:
603 await session.contacts._fill(orm)
604 if not (r := session.contacts.ready).done():
605 r.set_result(True)
606 if self.GROUPS:
607 session.send_gateway_status("Syncing groups…", show="dnd")
608 await session.bookmarks.fill()
609 if not (r := session.bookmarks.ready).done():
610 r.set_result(True)
611 self.send_presence(pto=session.user.jid.bare, ptype="probe")
612 if status is None:
613 session.send_gateway_status("Logged in", show="chat")
614 else:
615 session.send_gateway_status(status, show="chat")
616 if session.user.preferences.get("sync_avatar", False):
617 session.create_task(self.fetch_user_avatar(session))
618 else:
619 with self.store.session(expire_on_commit=False) as orm:
620 session.user.avatar_hash = None
621 orm.add(session.user)
622 orm.commit()
624 async def fetch_user_avatar(self, session: BaseSession) -> None:
625 try:
626 iq = await self.xmpp.plugin["xep_0060"].get_items(
627 session.user_jid.bare,
628 self.xmpp.plugin["xep_0084"].stanza.MetaData.namespace,
629 ifrom=self.boundjid.bare,
630 )
631 except IqTimeout:
632 self.log.warning("Iq timeout trying to fetch user avatar")
633 return
634 except IqError as e:
635 self.log.debug("Iq error when trying to fetch user avatar: %s", e)
636 if e.condition == "item-not-found":
637 try:
638 await session.on_avatar(None, None, None, None, None)
639 except NotImplementedError:
640 pass
641 else:
642 with self.store.session(expire_on_commit=False) as orm:
643 session.user.avatar_hash = None
644 orm.add(session.user)
645 orm.commit()
646 return
647 await self.__dispatcher.on_avatar_metadata_info(
648 session, iq["pubsub"]["items"]["item"]["avatar_metadata"]["info"]
649 )
651 def _send(
652 self, stanza: MessageOrPresenceTypeVar, **send_kwargs
653 ) -> MessageOrPresenceTypeVar:
654 stanza.set_from(self.boundjid.bare)
655 if mto := send_kwargs.get("mto"):
656 stanza.set_to(mto)
657 stanza.send()
658 return stanza
660 def raise_if_not_allowed_jid(self, jid: JID):
661 if not self.jid_validator.match(jid.bare):
662 raise XMPPError(
663 condition="not-allowed",
664 text="Your account is not allowed to use this gateway.",
665 )
667 def send_raw(self, data: Union[str, bytes]):
668 # overridden from XMLStream to strip base64-encoded data from the logs
669 # to make them more readable.
670 if log.isEnabledFor(level=logging.DEBUG):
671 if isinstance(data, str):
672 stripped = copy(data)
673 else:
674 stripped = data.decode("utf-8")
675 # there is probably a way to do that in a single RE,
676 # but since it's only for debugging, the perf penalty
677 # does not matter much
678 for el in LOG_STRIP_ELEMENTS:
679 stripped = re.sub(
680 f"(<{el}.*?>)(.*)(</{el}>)",
681 "\1[STRIPPED]\3",
682 stripped,
683 flags=re.DOTALL | re.IGNORECASE,
684 )
685 log.debug("SEND: %s", stripped)
686 if not self.transport:
687 raise NotConnectedError()
688 if isinstance(data, str):
689 data = data.encode("utf-8")
690 self.transport.write(data)
692 def get_session_from_jid(self, j: JID):
693 try:
694 return self._session_cls.from_jid(j)
695 except XMPPError:
696 pass
698 def exception(self, exception: Exception) -> None:
699 # """
700 # Called when a task created by slixmpp's internal (eg, on slix events) raises an Exception.
701 #
702 # Stop the event loop and exit on unhandled exception.
703 #
704 # The default :class:`slixmpp.basexmpp.BaseXMPP` behaviour is just to
705 # log the exception, but we want to avoid undefined behaviour.
706 #
707 # :param exception: An unhandled :class:`Exception` object.
708 # """
709 if isinstance(exception, IqError):
710 iq = exception.iq
711 log.error("%s: %s", iq["error"]["condition"], iq["error"]["text"])
712 log.warning("You should catch IqError exceptions")
713 elif isinstance(exception, IqTimeout):
714 iq = exception.iq
715 log.error("Request timed out: %s", iq)
716 log.warning("You should catch IqTimeout exceptions")
717 elif isinstance(exception, SyntaxError):
718 # Hide stream parsing errors that occur when the
719 # stream is disconnected (they've been handled, we
720 # don't need to make a mess in the logs).
721 pass
722 else:
723 if exception:
724 log.exception(exception)
725 self.loop.stop()
726 exit(1)
728 def re_login(self, session: "BaseSession") -> None:
729 async def w() -> None:
730 session.cancel_all_tasks()
731 try:
732 await session.logout()
733 except NotImplementedError:
734 pass
735 await self.login_wrap(session)
737 session.create_task(w())
739 async def make_registration_form(
740 self, _jid: JID, _node: str, _ifrom: JID, iq: Iq
741 ) -> Form:
742 self.raise_if_not_allowed_jid(iq.get_from())
743 reg = iq["register"]
744 with self.store.session() as orm:
745 user = (
746 orm.query(GatewayUser).filter_by(jid=iq.get_from().bare).one_or_none()
747 )
748 log.debug("User found: %s", user)
750 form = reg["form"]
751 form.add_field(
752 "FORM_TYPE",
753 ftype="hidden",
754 value="jabber:iq:register",
755 )
756 form["title"] = f"Registration to '{self.COMPONENT_NAME}'"
757 form["instructions"] = self.REGISTRATION_INSTRUCTIONS
759 if user is not None:
760 reg["registered"] = False
761 form.add_field(
762 "remove",
763 label="Remove my registration",
764 required=True,
765 ftype="boolean",
766 value=False,
767 )
769 for field in self.REGISTRATION_FIELDS:
770 if field.var in reg.interfaces:
771 val = None if user is None else user.get(field.var)
772 if val is None:
773 reg.add_field(field.var)
774 else:
775 reg[field.var] = val
777 reg["instructions"] = self.REGISTRATION_INSTRUCTIONS
779 for field in self.REGISTRATION_FIELDS:
780 form.add_field(
781 field.var,
782 label=field.label,
783 required=field.required,
784 ftype=field.type,
785 options=field.options,
786 value=field.value if user is None else user.get(field.var, field.value),
787 )
789 reply = iq.reply()
790 reply.set_payload(reg)
791 return reply
793 async def user_prevalidate(
794 self, ifrom: JID, form_dict: dict[str, Optional[str]]
795 ) -> JSONSerializable | None:
796 # Pre validate a registration form using the content of self.REGISTRATION_FIELDS
797 # before passing it to the plugin custom validation logic.
798 for field in self.REGISTRATION_FIELDS:
799 if field.required and not form_dict.get(field.var):
800 raise ValueError(f"Missing field: '{field.label}'")
802 return await self.validate(ifrom, form_dict)
804 async def validate(
805 self, user_jid: JID, registration_form: dict[str, Optional[str]]
806 ) -> JSONSerializable | None:
807 """
808 Validate a user's initial registration form.
810 Should raise the appropriate :class:`slixmpp.exceptions.XMPPError`
811 if the registration does not allow to continue the registration process.
813 If :py:attr:`REGISTRATION_TYPE` is a
814 :attr:`.RegistrationType.SINGLE_STEP_FORM`,
815 this method should raise something if it wasn't possible to successfully
816 log in to the legacy service with the registration form content.
818 It is also used for other types of :py:attr:`REGISTRATION_TYPE` too, since
819 the first step is always a form. If :attr:`.REGISTRATION_FIELDS` is an
820 empty list (ie, it declares no :class:`.FormField`), the "form" is
821 effectively a confirmation dialog displaying
822 :attr:`.REGISTRATION_INSTRUCTIONS`.
824 :param user_jid: JID of the user that has just registered
825 :param registration_form: A dict where keys are the :attr:`.FormField.var` attributes
826 of the :attr:`.BaseGateway.REGISTRATION_FIELDS` iterable.
827 This dict can be modified and will be accessible as the ``legacy_module_data``
828 of the
830 :return : A dict that will be stored as the persistent "legacy_module_data"
831 for this user. If you don't return anything here, the whole registration_form
832 content will be stored.
833 """
834 raise NotImplementedError
836 async def validate_two_factor_code(
837 self, user: GatewayUser, code: str
838 ) -> JSONSerializable | None:
839 """
840 Called when the user enters their 2FA code.
842 Should raise the appropriate :class:`slixmpp.exceptions.XMPPError`
843 if the login fails, and return successfully otherwise.
845 Only used when :attr:`REGISTRATION_TYPE` is
846 :attr:`.RegistrationType.TWO_FACTOR_CODE`.
848 :param user: The :class:`.GatewayUser` whose registration is pending
849 Use their :attr:`.GatewayUser.bare_jid` and/or
850 :attr:`.registration_form` attributes to get what you need.
851 :param code: The code they entered, either via "chatbot" message or
852 adhoc command
854 :return : A dict which keys and values will be added to the persistent "legacy_module_data"
855 for this user.
856 """
857 raise NotImplementedError
859 async def get_qr_text(self, user: GatewayUser) -> str:
860 """
861 This is where slidge gets the QR code content for the QR-based
862 registration process. It will turn it into a QR code image and send it
863 to the not-yet-fully-registered :class:`.GatewayUser`.
865 Only used in when :attr:`BaseGateway.REGISTRATION_TYPE` is
866 :attr:`.RegistrationType.QRCODE`.
868 :param user: The :class:`.GatewayUser` whose registration is pending
869 Use their :attr:`.GatewayUser.bare_jid` and/or
870 :attr:`.registration_form` attributes to get what you need.
871 """
872 raise NotImplementedError
874 async def confirm_qr(
875 self,
876 user_bare_jid: str,
877 exception: Optional[Exception] = None,
878 legacy_data: Optional[JSONSerializable] = None,
879 ) -> None:
880 """
881 This method is meant to be called to finalize QR code-based registration
882 flows, once the legacy service confirms the QR flashing.
884 Only used in when :attr:`BaseGateway.REGISTRATION_TYPE` is
885 :attr:`.RegistrationType.QRCODE`.
887 :param user_bare_jid: The bare JID of the almost-registered
888 :class:`GatewayUser` instance
889 :param exception: Optionally, an XMPPError to be raised to **not** confirm
890 QR code flashing.
891 :param legacy_data: dict which keys and values will be added to the persistent
892 "legacy_module_data" for this user.
893 """
894 fut = self.qr_pending_registrations[user_bare_jid]
895 if exception is None:
896 fut.set_result(legacy_data)
897 else:
898 fut.set_exception(exception)
900 async def unregister_user(self, user: GatewayUser) -> None:
901 self.send_presence(
902 pshow="dnd",
903 pstatus="You unregistered from this gateway.",
904 pto=user.jid,
905 )
906 await self.xmpp.plugin["xep_0077"].api["user_remove"](None, None, user.jid)
907 await self.xmpp._session_cls.kill_by_jid(user.jid)
909 async def unregister(self, user: GatewayUser) -> None:
910 """
911 Optionally override this if you need to clean additional
912 stuff after a user has been removed from the persistent user store.
914 By default, this just calls :meth:`BaseSession.logout`.
916 :param user:
917 """
918 session = self.get_session_from_user(user)
919 try:
920 await session.logout()
921 except NotImplementedError:
922 pass
924 async def input(
925 self,
926 jid: JID,
927 text: str | None = None,
928 mtype: MessageTypes = "chat",
929 **input_kwargs: Any,
930 ) -> str:
931 """
932 Request arbitrary user input using a simple chat message, and await the result.
934 You shouldn't need to call this directly bust instead use
935 :meth:`.BaseSession.input` to directly target a user.
937 :param jid: The JID we want input from
938 :param text: A prompt to display for the user
939 :param mtype: Message type
940 :return: The user's reply
941 """
942 return await self.__chat_commands_handler.input(
943 jid, text, mtype=mtype, **input_kwargs
944 )
946 async def send_qr(self, text: str, **msg_kwargs: Any) -> None:
947 """
948 Sends a QR Code to a JID
950 You shouldn't need to call directly bust instead use
951 :meth:`.BaseSession.send_qr` to directly target a user.
953 :param text: The text that will be converted to a QR Code
954 :param msg_kwargs: Optional additional arguments to pass to
955 :meth:`.BaseGateway.send_file`, such as the recipient of the QR,
956 code
957 """
958 qr = qrcode.make(text)
959 with tempfile.NamedTemporaryFile(
960 suffix=".png", delete=config.NO_UPLOAD_METHOD != "move"
961 ) as f:
962 qr.save(f.name)
963 await self.send_file(Path(f.name), **msg_kwargs)
965 def shutdown(self) -> list[asyncio.Task[None]]:
966 # """
967 # Called by the slidge entrypoint on normal exit.
968 #
969 # Sends offline presences from all contacts of all user sessions and from
970 # the gateway component itself.
971 # No need to call this manually, :func:`slidge.__main__.main` should take care of it.
972 # """
973 log.debug("Shutting down")
974 tasks = []
975 with self.store.session() as orm:
976 for user in orm.query(GatewayUser).all():
977 tasks.append(self._session_cls.from_jid(user.jid).shutdown())
978 self.send_presence(ptype="unavailable", pto=user.jid)
979 return tasks
982SLIXMPP_PLUGINS = [
983 "link_preview", # https://wiki.soprani.ca/CheogramApp/LinkPreviews
984 "xep_0030", # Service discovery
985 "xep_0045", # Multi-User Chat
986 "xep_0050", # Adhoc commands
987 "xep_0054", # VCard-temp (for MUC avatars)
988 "xep_0055", # Jabber search
989 "xep_0059", # Result Set Management
990 "xep_0066", # Out of Band Data
991 "xep_0071", # XHTML-IM (for stickers and custom emojis maybe later)
992 "xep_0077", # In-band registration
993 "xep_0084", # User Avatar
994 "xep_0085", # Chat state notifications
995 "xep_0100", # Gateway interaction
996 "xep_0106", # JID Escaping
997 "xep_0115", # Entity capabilities
998 "xep_0122", # Data Forms Validation
999 "xep_0153", # vCard-Based Avatars (for MUC avatars)
1000 "xep_0172", # User nickname
1001 "xep_0184", # Message Delivery Receipts
1002 "xep_0199", # XMPP Ping
1003 "xep_0221", # Data Forms Media Element
1004 "xep_0231", # Bits of Binary (for stickers and custom emojis maybe later)
1005 "xep_0249", # Direct MUC Invitations
1006 "xep_0264", # Jingle Content Thumbnails
1007 "xep_0280", # Carbons
1008 "xep_0292_provider", # VCard4
1009 "xep_0308", # Last message correction
1010 "xep_0313", # Message Archive Management
1011 "xep_0317", # Hats
1012 "xep_0319", # Last User Interaction in Presence
1013 "xep_0333", # Chat markers
1014 "xep_0334", # Message Processing Hints
1015 "xep_0356", # Privileged Entity
1016 "xep_0363", # HTTP file upload
1017 "xep_0385", # Stateless in-line media sharing
1018 "xep_0402", # PEP Native Bookmarks
1019 "xep_0421", # Anonymous unique occupant identifiers for MUCs
1020 "xep_0424", # Message retraction
1021 "xep_0425", # Message moderation
1022 "xep_0444", # Message reactions
1023 "xep_0447", # Stateless File Sharing
1024 "xep_0461", # Message replies
1025 "xep_0469", # Bookmark Pinning
1026 "xep_0490", # Message Displayed Synchronization
1027 "xep_0492", # Chat Notification Settings
1028]
1030LOG_STRIP_ELEMENTS = ["data", "binval"]
1032log = logging.getLogger(__name__)