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