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