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