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