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