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