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