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