Coverage for slidge/core/gateway.py: 66%
357 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-07 05:11 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-07 05:11 +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 typing import TYPE_CHECKING, Any, Callable, Mapping, Optional, Sequence, Union
13import aiohttp
14import qrcode
15from slixmpp import JID, ComponentXMPP, Iq, Message, Presence
16from slixmpp.exceptions import IqError, IqTimeout, XMPPError
17from slixmpp.plugins.xep_0060.stanza import OwnerAffiliation
18from slixmpp.types import MessageTypes
19from slixmpp.xmlstream.xmlstream import NotConnectedError
21from slidge import command # noqa: F401
22from slidge.command.adhoc import AdhocProvider
23from slidge.command.admin import Exec
24from slidge.command.base import Command, FormField
25from slidge.command.chat_command import ChatCommandProvider
26from slidge.command.register import RegistrationType
27from slidge.core import config
28from slidge.core.dispatcher.session_dispatcher import SessionDispatcher
29from slidge.core.mixins import MessageMixin
30from slidge.core.pubsub import PubSubComponent
31from slidge.core.session import BaseSession
32from slidge.db import GatewayUser, SlidgeStore
33from slidge.db.avatar import avatar_cache
34from slidge.slixfix.delivery_receipt import DeliveryReceipt
35from slidge.slixfix.roster import RosterBackend
36from slidge.util import ABCSubclassableOnceAtMost
37from slidge.util.types import AvatarType, MessageOrPresenceTypeVar
38from slidge.util.util import timeit
40if TYPE_CHECKING:
41 pass
44class BaseGateway(
45 ComponentXMPP,
46 MessageMixin,
47 metaclass=ABCSubclassableOnceAtMost,
48):
49 """
50 The gateway component, handling registrations and un-registrations.
52 On slidge launch, a singleton is instantiated, and it will be made available
53 to public classes such :class:`.LegacyContact` or :class:`.BaseSession` as the
54 ``.xmpp`` attribute.
56 Must be subclassed by a legacy module to set up various aspects of the XMPP
57 component behaviour, such as its display name or welcome message, via
58 class attributes :attr:`.COMPONENT_NAME` :attr:`.WELCOME_MESSAGE`.
60 Abstract methods related to the registration process must be overriden
61 for a functional :term:`Legacy Module`:
63 - :meth:`.validate`
64 - :meth:`.validate_two_factor_code`
65 - :meth:`.get_qr_text`
66 - :meth:`.confirm_qr`
68 NB: Not all of these must be overridden, it depends on the
69 :attr:`REGISTRATION_TYPE`.
71 The other methods, such as :meth:`.send_text` or :meth:`.react` are the same
72 as those of :class:`.LegacyContact` and :class:`.LegacyParticipant`, because
73 the component itself is also a "messaging actor", ie, an :term:`XMPP Entity`.
74 For these methods, you need to specify the JID of the recipient with the
75 `mto` parameter.
77 Since it inherits from :class:`slixmpp.componentxmpp.ComponentXMPP`,you also
78 have a hand on low-level XMPP interactions via slixmpp methods, e.g.:
80 .. code-block:: python
82 self.send_presence(
83 pfrom="somebody@component.example.com",
84 pto="someonwelse@anotherexample.com",
85 )
87 However, you should not need to do so often since the classes of the plugin
88 API provides higher level abstractions around most commonly needed use-cases, such
89 as sending messages, or displaying a custom status.
91 """
93 COMPONENT_NAME: str = NotImplemented
94 """Name of the component, as seen in service discovery by XMPP clients"""
95 COMPONENT_TYPE: str = ""
96 """Type of the gateway, should follow https://xmpp.org/registrar/disco-categories.html"""
97 COMPONENT_AVATAR: Optional[AvatarType] = None
98 """
99 Path, bytes or URL used by the component as an avatar.
100 """
102 REGISTRATION_FIELDS: Sequence[FormField] = [
103 FormField(var="username", label="User name", required=True),
104 FormField(var="password", label="Password", required=True, private=True),
105 ]
106 """
107 Iterable of fields presented to the gateway user when registering using :xep:`0077`
108 `extended <https://xmpp.org/extensions/xep-0077.html#extensibility>`_ by :xep:`0004`.
109 """
110 REGISTRATION_INSTRUCTIONS: str = "Enter your credentials"
111 """
112 The text presented to a user that wants to register (or modify) their legacy account
113 configuration.
114 """
115 REGISTRATION_TYPE: RegistrationType = RegistrationType.SINGLE_STEP_FORM
116 """
117 This attribute determines how users register to the gateway, ie, how they
118 login to the :term:`legacy service <Legacy Service>`.
119 The credentials are then stored persistently, so this process should happen
120 once per user (unless they unregister).
122 The registration process always start with a basic data form (:xep:`0004`)
123 presented to the user.
124 But the legacy login flow might require something more sophisticated, see
125 :class:`.RegistrationType` for more details.
126 """
128 REGISTRATION_2FA_TITLE = "Enter your 2FA code"
129 REGISTRATION_2FA_INSTRUCTIONS = (
130 "You should have received something via email or SMS, or something"
131 )
132 REGISTRATION_QR_INSTRUCTIONS = "Flash this code or follow this link"
134 PREFERENCES = [
135 FormField(
136 var="sync_presence",
137 label="Propagate your XMPP presence to the legacy network.",
138 value="true",
139 required=True,
140 type="boolean",
141 ),
142 FormField(
143 var="sync_avatar",
144 label="Propagate your XMPP avatar to the legacy network.",
145 value="true",
146 required=True,
147 type="boolean",
148 ),
149 ]
151 ROSTER_GROUP: str = "slidge"
152 """
153 Name of the group assigned to a :class:`.LegacyContact` automagically
154 added to the :term:`User`'s roster with :meth:`.LegacyContact.add_to_roster`.
155 """
156 WELCOME_MESSAGE = (
157 "Thank you for registering. Type 'help' to list the available commands, "
158 "or just start messaging away!"
159 )
160 """
161 A welcome message displayed to users on registration.
162 This is useful notably for clients that don't consider component JIDs as a
163 valid recipient in their UI, yet still open a functional chat window on
164 incoming messages from components.
165 """
167 SEARCH_FIELDS: Sequence[FormField] = [
168 FormField(var="first", label="First name", required=True),
169 FormField(var="last", label="Last name", required=True),
170 FormField(var="phone", label="Phone number", required=False),
171 ]
172 """
173 Fields used for searching items via the component, through :xep:`0055` (jabber search).
174 A common use case is to allow users to search for legacy contacts by something else than
175 their usernames, eg their phone number.
177 Plugins should implement search by overriding :meth:`.BaseSession.search`
178 (restricted to registered users).
180 If there is only one field, it can also be used via the ``jabber:iq:gateway`` protocol
181 described in :xep:`0100`. Limitation: this only works if the search request returns
182 one result item, and if this item has a 'jid' var.
183 """
184 SEARCH_TITLE: str = "Search for legacy contacts"
185 """
186 Title of the search form.
187 """
188 SEARCH_INSTRUCTIONS: str = ""
189 """
190 Instructions of the search form.
191 """
193 MARK_ALL_MESSAGES = False
194 """
195 Set this to True for :term:`legacy networks <Legacy Network>` that expects
196 read marks for *all* messages and not just the latest one that was read
197 (as most XMPP clients will only send a read mark for the latest msg).
198 """
200 PROPER_RECEIPTS = False
201 """
202 Set this to True if the legacy service provides a real equivalent of message delivery receipts
203 (:xep:`0184`), meaning that there is an event thrown when the actual device of a contact receives
204 a message. Make sure to call Contact.received() adequately if this is set to True.
205 """
207 GROUPS = False
209 mtype: MessageTypes = "chat"
210 is_group = False
211 _can_send_carbon = False
212 store: SlidgeStore
213 avatar_pk: int
215 AVATAR_ID_TYPE: Callable[[str], Any] = str
216 """
217 Modify this if the legacy network uses unique avatar IDs that are not strings.
219 This is required because we store those IDs as TEXT in the persistent SQL DB.
220 The callable specified here will receive is responsible for converting the
221 serialised-as-text version of the avatar unique ID back to the proper type.
222 Common example: ``int``.
223 """
224 # FIXME: do we really need this since we have session.xmpp_to_legacy_msg_id?
225 # (maybe we do)
226 LEGACY_MSG_ID_TYPE: Callable[[str], Any] = str
227 """
228 Modify this if the legacy network uses unique message IDs that are not strings.
230 This is required because we store those IDs as TEXT in the persistent SQL DB.
231 The callable specified here will receive is responsible for converting the
232 serialised-as-text version of the message unique ID back to the proper type.
233 Common example: ``int``.
234 """
235 LEGACY_CONTACT_ID_TYPE: Callable[[str], Any] = str
236 """
237 Modify this if the legacy network uses unique contact IDs that are not strings.
239 This is required because we store those IDs as TEXT in the persistent SQL DB.
240 The callable specified here is responsible for converting the
241 serialised-as-text version of the contact unique ID back to the proper type.
242 Common example: ``int``.
243 """
244 LEGACY_ROOM_ID_TYPE: Callable[[str], Any] = str
245 """
246 Modify this if the legacy network uses unique room IDs that are not strings.
248 This is required because we store those IDs as TEXT in the persistent SQL DB.
249 The callable specified here is responsible for converting the
250 serialised-as-text version of the room unique ID back to the proper type.
251 Common example: ``int``.
252 """
254 http: aiohttp.ClientSession
256 def __init__(self):
257 self.log = log
258 self.datetime_started = datetime.now()
259 self.xmpp = self # ugly hack to work with the BaseSender mixin :/
260 self.default_ns = "jabber:component:accept"
261 super().__init__(
262 config.JID,
263 config.SECRET,
264 config.SERVER,
265 config.PORT,
266 plugin_whitelist=SLIXMPP_PLUGINS,
267 plugin_config={
268 "xep_0077": {
269 "form_fields": None,
270 "form_instructions": self.REGISTRATION_INSTRUCTIONS,
271 "enable_subscription": self.REGISTRATION_TYPE
272 == RegistrationType.SINGLE_STEP_FORM,
273 },
274 "xep_0100": {
275 "component_name": self.COMPONENT_NAME,
276 "type": self.COMPONENT_TYPE,
277 },
278 "xep_0184": {
279 "auto_ack": False,
280 "auto_request": False,
281 },
282 "xep_0363": {
283 "upload_service": config.UPLOAD_SERVICE,
284 },
285 },
286 fix_error_ns=True,
287 )
288 self.loop.set_exception_handler(self.__exception_handler)
289 self.loop.create_task(self.__set_http())
290 self.has_crashed: bool = False
291 self.use_origin_id = False
293 self.jid_validator: re.Pattern = re.compile(config.USER_JID_VALIDATOR)
294 self.qr_pending_registrations = dict[str, asyncio.Future[Optional[dict]]]()
296 self.session_cls: BaseSession = BaseSession.get_unique_subclass()
297 self.session_cls.xmpp = self
299 from ..group.room import LegacyMUC
301 LegacyMUC.get_self_or_unique_subclass().xmpp = self
303 self.get_session_from_stanza: Callable[
304 [Union[Message, Presence, Iq]], BaseSession
305 ] = self.session_cls.from_stanza # type: ignore
306 self.get_session_from_user: Callable[[GatewayUser], BaseSession] = (
307 self.session_cls.from_user
308 )
310 self.register_plugins()
311 self.__register_slixmpp_events()
312 self.__register_slixmpp_api()
313 self.roster.set_backend(RosterBackend(self))
315 self.register_plugin("pubsub", {"component_name": self.COMPONENT_NAME})
316 self.pubsub: PubSubComponent = self["pubsub"]
317 self.delivery_receipt: DeliveryReceipt = DeliveryReceipt(self)
319 # with this we receive user avatar updates
320 self.plugin["xep_0030"].add_feature("urn:xmpp:avatar:metadata+notify")
322 self.plugin["xep_0030"].add_feature("urn:xmpp:chat-markers:0")
324 if self.GROUPS:
325 self.plugin["xep_0030"].add_feature("http://jabber.org/protocol/muc")
326 self.plugin["xep_0030"].add_feature("urn:xmpp:mam:2")
327 self.plugin["xep_0030"].add_feature("urn:xmpp:mam:2#extended")
328 self.plugin["xep_0030"].add_feature(self.plugin["xep_0421"].namespace)
329 self.plugin["xep_0030"].add_feature(self["xep_0317"].stanza.NS)
330 self.plugin["xep_0030"].add_identity(
331 category="conference",
332 name=self.COMPONENT_NAME,
333 itype="text",
334 jid=self.boundjid,
335 )
337 # why does mypy need these type annotations? no idea
338 self.__adhoc_handler: AdhocProvider = AdhocProvider(self)
339 self.__chat_commands_handler: ChatCommandProvider = ChatCommandProvider(self)
341 self.__dispatcher = SessionDispatcher(self)
343 self.__register_commands()
345 MessageMixin.__init__(self) # ComponentXMPP does not call super().__init__()
347 async def __set_http(self):
348 self.http = aiohttp.ClientSession()
349 if getattr(self, "_test_mode", False):
350 return
351 avatar_cache.http = self.http
353 def __register_commands(self):
354 for cls in Command.subclasses:
355 if any(x is NotImplemented for x in [cls.CHAT_COMMAND, cls.NODE, cls.NAME]):
356 log.debug("Not adding command '%s' because it looks abstract", cls)
357 continue
358 if cls is Exec:
359 if config.DEV_MODE:
360 log.warning(r"/!\ DEV MODE ENABLED /!\\")
361 else:
362 continue
363 c = cls(self)
364 log.debug("Registering %s", cls)
365 self.__adhoc_handler.register(c)
366 self.__chat_commands_handler.register(c)
368 def __exception_handler(self, loop: asyncio.AbstractEventLoop, context):
369 """
370 Called when a task created by loop.create_task() raises an Exception
372 :param loop:
373 :param context:
374 :return:
375 """
376 log.debug("Context in the exception handler: %s", context)
377 exc = context.get("exception")
378 if exc is None:
379 log.debug("No exception in this context: %s", context)
380 elif isinstance(exc, SystemExit):
381 log.debug("SystemExit called in an asyncio task")
382 else:
383 log.error("Crash in an asyncio task: %s", context)
384 log.exception("Crash in task", exc_info=exc)
385 self.has_crashed = True
386 loop.stop()
388 def __register_slixmpp_events(self):
389 self.del_event_handler("presence_subscribe", self._handle_subscribe)
390 self.del_event_handler("presence_unsubscribe", self._handle_unsubscribe)
391 self.del_event_handler("presence_subscribed", self._handle_subscribed)
392 self.del_event_handler("presence_unsubscribed", self._handle_unsubscribed)
393 self.del_event_handler(
394 "roster_subscription_request", self._handle_new_subscription
395 )
396 self.del_event_handler("presence_probe", self._handle_probe)
397 self.add_event_handler("session_start", self.__on_session_start)
398 self.add_event_handler("disconnected", self.connect)
400 def __register_slixmpp_api(self) -> None:
401 self.plugin["xep_0231"].api.register(self.store.bob.get_bob, "get_bob")
402 self.plugin["xep_0231"].api.register(self.store.bob.set_bob, "set_bob")
403 self.plugin["xep_0231"].api.register(self.store.bob.del_bob, "del_bob")
405 @property # type: ignore
406 def jid(self):
407 # Override to avoid slixmpp deprecation warnings.
408 return self.boundjid
410 async def __on_session_start(self, event):
411 log.debug("Gateway session start: %s", event)
413 # prevents XMPP clients from considering the gateway as an HTTP upload
414 disco = self.plugin["xep_0030"]
415 await disco.del_feature(feature="urn:xmpp:http:upload:0", jid=self.boundjid)
416 await self.plugin["xep_0115"].update_caps(jid=self.boundjid)
418 if self.COMPONENT_AVATAR is not None:
419 cached_avatar = await avatar_cache.convert_or_get(self.COMPONENT_AVATAR)
420 self.avatar_pk = cached_avatar.pk
421 else:
422 cached_avatar = None
424 for user in self.store.users.get_all():
425 # TODO: before this, we should check if the user has removed us from their roster
426 # while we were offline and trigger unregister from there. Presence probe does not seem
427 # to work in this case, there must be another way. privileged entity could be used
428 # as last resort.
429 try:
430 await self["xep_0100"].add_component_to_roster(user.jid)
431 await self.__add_component_to_mds_whitelist(user.jid)
432 except (IqError, IqTimeout) as e:
433 # TODO: remove the user when this happens? or at least
434 # this can happen when the user has unsubscribed from the XMPP server
435 log.warning(
436 "Error with user %s, not logging them automatically",
437 user,
438 exc_info=e,
439 )
440 continue
441 self.send_presence(
442 pto=user.jid.bare, ptype="probe"
443 ) # ensure we get all resources for user
444 session = self.session_cls.from_user(user)
445 session.create_task(self.login_wrap(session))
446 if cached_avatar is not None:
447 await self.pubsub.broadcast_avatar(
448 self.boundjid.bare, session.user_jid, cached_avatar
449 )
451 log.info("Slidge has successfully started")
453 async def __add_component_to_mds_whitelist(self, user_jid: JID):
454 # Uses privileged entity to add ourselves to the whitelist of the PEP
455 # MDS node so we receive MDS events
456 iq_creation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set")
457 iq_creation["pubsub"]["create"]["node"] = self["xep_0490"].stanza.NS
459 try:
460 await self["xep_0356"].send_privileged_iq(iq_creation)
461 except PermissionError:
462 log.warning(
463 "IQ privileges not granted for pubsub namespace, we cannot "
464 "create the MDS node of %s",
465 user_jid,
466 )
467 except (IqError, IqTimeout) as e:
468 # conflict this means the node already exists, we can ignore that
469 if e.condition != "conflict":
470 log.exception(
471 "Could not create the MDS node of %s", user_jid, exc_info=e
472 )
473 except Exception as e:
474 log.exception(
475 "Error while trying to create to the MDS node of %s",
476 user_jid,
477 exc_info=e,
478 )
480 iq_affiliation = Iq(sto=user_jid.bare, sfrom=user_jid, stype="set")
481 iq_affiliation["pubsub_owner"]["affiliations"]["node"] = self[
482 "xep_0490"
483 ].stanza.NS
485 aff = OwnerAffiliation()
486 aff["jid"] = self.boundjid.bare
487 aff["affiliation"] = "member"
488 iq_affiliation["pubsub_owner"]["affiliations"].append(aff)
490 try:
491 await self["xep_0356"].send_privileged_iq(iq_affiliation)
492 except PermissionError:
493 log.warning(
494 "IQ privileges not granted for pubsub#owner namespace, we cannot "
495 "listen to the MDS events of %s",
496 user_jid,
497 )
498 except Exception as e:
499 log.exception(
500 "Error while trying to subscribe to the MDS node of %s",
501 user_jid,
502 exc_info=e,
503 )
505 @timeit
506 async def login_wrap(self, session: "BaseSession"):
507 session.send_gateway_status("Logging in…", show="dnd")
508 try:
509 status = await session.login()
510 except Exception as e:
511 log.warning("Login problem for %s", session.user_jid, exc_info=e)
512 log.exception(e)
513 session.send_gateway_status(f"Could not login: {e}", show="busy")
514 session.send_gateway_message(
515 "You are not connected to this gateway! "
516 f"Maybe this message will tell you why: {e}"
517 )
518 return
520 log.info("Login success for %s", session.user_jid)
521 session.logged = True
522 session.send_gateway_status("Syncing contacts…", show="dnd")
523 await session.contacts._fill()
524 if not (r := session.contacts.ready).done():
525 r.set_result(True)
526 if self.GROUPS:
527 session.send_gateway_status("Syncing groups…", show="dnd")
528 await session.bookmarks.fill()
529 if not (r := session.bookmarks.ready).done():
530 r.set_result(True)
531 for c in session.contacts:
532 # we need to receive presences directed at the contacts, in
533 # order to send pubsub events for their +notify features
534 self.send_presence(pfrom=c.jid, pto=session.user_jid.bare, ptype="probe")
535 if status is None:
536 session.send_gateway_status("Logged in", show="chat")
537 else:
538 session.send_gateway_status(status, show="chat")
539 if session.user.preferences.get("sync_avatar", False):
540 session.create_task(self.fetch_user_avatar(session))
541 else:
542 self.xmpp.store.users.set_avatar_hash(session.user_pk, None)
544 async def fetch_user_avatar(self, session: BaseSession):
545 try:
546 iq = await self.xmpp.plugin["xep_0060"].get_items(
547 session.user_jid.bare,
548 self.xmpp.plugin["xep_0084"].stanza.MetaData.namespace,
549 ifrom=self.boundjid.bare,
550 )
551 except (IqError, IqTimeout):
552 self.xmpp.store.users.set_avatar_hash(session.user_pk, None)
553 return
554 await self.__dispatcher.on_avatar_metadata_info(
555 session, iq["pubsub"]["items"]["item"]["avatar_metadata"]["info"]
556 )
558 def _send(
559 self, stanza: MessageOrPresenceTypeVar, **send_kwargs
560 ) -> MessageOrPresenceTypeVar:
561 stanza.set_from(self.boundjid.bare)
562 if mto := send_kwargs.get("mto"):
563 stanza.set_to(mto)
564 stanza.send()
565 return stanza
567 def raise_if_not_allowed_jid(self, jid: JID):
568 if not self.jid_validator.match(jid.bare):
569 raise XMPPError(
570 condition="not-allowed",
571 text="Your account is not allowed to use this gateway.",
572 )
574 def send_raw(self, data: Union[str, bytes]):
575 # overridden from XMLStream to strip base64-encoded data from the logs
576 # to make them more readable.
577 if log.isEnabledFor(level=logging.DEBUG):
578 if isinstance(data, str):
579 stripped = copy(data)
580 else:
581 stripped = data.decode("utf-8")
582 # there is probably a way to do that in a single RE,
583 # but since it's only for debugging, the perf penalty
584 # does not matter much
585 for el in LOG_STRIP_ELEMENTS:
586 stripped = re.sub(
587 f"(<{el}.*?>)(.*)(</{el}>)",
588 "\1[STRIPPED]\3",
589 stripped,
590 flags=re.DOTALL | re.IGNORECASE,
591 )
592 log.debug("SEND: %s", stripped)
593 if not self.transport:
594 raise NotConnectedError()
595 if isinstance(data, str):
596 data = data.encode("utf-8")
597 self.transport.write(data)
599 def get_session_from_jid(self, j: JID):
600 try:
601 return self.session_cls.from_jid(j)
602 except XMPPError:
603 pass
605 def exception(self, exception: Exception):
606 # """
607 # Called when a task created by slixmpp's internal (eg, on slix events) raises an Exception.
608 #
609 # Stop the event loop and exit on unhandled exception.
610 #
611 # The default :class:`slixmpp.basexmpp.BaseXMPP` behaviour is just to
612 # log the exception, but we want to avoid undefined behaviour.
613 #
614 # :param exception: An unhandled :class:`Exception` object.
615 # """
616 if isinstance(exception, IqError):
617 iq = exception.iq
618 log.error("%s: %s", iq["error"]["condition"], iq["error"]["text"])
619 log.warning("You should catch IqError exceptions")
620 elif isinstance(exception, IqTimeout):
621 iq = exception.iq
622 log.error("Request timed out: %s", iq)
623 log.warning("You should catch IqTimeout exceptions")
624 elif isinstance(exception, SyntaxError):
625 # Hide stream parsing errors that occur when the
626 # stream is disconnected (they've been handled, we
627 # don't need to make a mess in the logs).
628 pass
629 else:
630 if exception:
631 log.exception(exception)
632 self.loop.stop()
633 exit(1)
635 def re_login(self, session: "BaseSession"):
636 async def w():
637 session.cancel_all_tasks()
638 await session.logout()
639 await self.login_wrap(session)
641 session.create_task(w())
643 async def make_registration_form(self, _jid, _node, _ifrom, iq: Iq):
644 self.raise_if_not_allowed_jid(iq.get_from())
645 reg = iq["register"]
646 user = self.store.users.get_by_stanza(iq)
647 log.debug("User found: %s", user)
649 form = reg["form"]
650 form.add_field(
651 "FORM_TYPE",
652 ftype="hidden",
653 value="jabber:iq:register",
654 )
655 form["title"] = f"Registration to '{self.COMPONENT_NAME}'"
656 form["instructions"] = self.REGISTRATION_INSTRUCTIONS
658 if user is not None:
659 reg["registered"] = False
660 form.add_field(
661 "remove",
662 label="Remove my registration",
663 required=True,
664 ftype="boolean",
665 value=False,
666 )
668 for field in self.REGISTRATION_FIELDS:
669 if field.var in reg.interfaces:
670 val = None if user is None else user.get(field.var)
671 if val is None:
672 reg.add_field(field.var)
673 else:
674 reg[field.var] = val
676 reg["instructions"] = self.REGISTRATION_INSTRUCTIONS
678 for field in self.REGISTRATION_FIELDS:
679 form.add_field(
680 field.var,
681 label=field.label,
682 required=field.required,
683 ftype=field.type,
684 options=field.options,
685 value=field.value if user is None else user.get(field.var, field.value),
686 )
688 reply = iq.reply()
689 reply.set_payload(reg)
690 return reply
692 async def user_prevalidate(
693 self, ifrom: JID, form_dict: dict[str, Optional[str]]
694 ) -> Optional[Mapping]:
695 # Pre validate a registration form using the content of self.REGISTRATION_FIELDS
696 # before passing it to the plugin custom validation logic.
697 for field in self.REGISTRATION_FIELDS:
698 if field.required and not form_dict.get(field.var):
699 raise ValueError(f"Missing field: '{field.label}'")
701 return await self.validate(ifrom, form_dict)
703 async def validate(
704 self, user_jid: JID, registration_form: dict[str, Optional[str]]
705 ) -> Optional[Mapping]:
706 """
707 Validate a user's initial registration form.
709 Should raise the appropriate :class:`slixmpp.exceptions.XMPPError`
710 if the registration does not allow to continue the registration process.
712 If :py:attr:`REGISTRATION_TYPE` is a
713 :attr:`.RegistrationType.SINGLE_STEP_FORM`,
714 this method should raise something if it wasn't possible to successfully
715 log in to the legacy service with the registration form content.
717 It is also used for other types of :py:attr:`REGISTRATION_TYPE` too, since
718 the first step is always a form. If :attr:`.REGISTRATION_FIELDS` is an
719 empty list (ie, it declares no :class:`.FormField`), the "form" is
720 effectively a confirmation dialog displaying
721 :attr:`.REGISTRATION_INSTRUCTIONS`.
723 :param user_jid: JID of the user that has just registered
724 :param registration_form: A dict where keys are the :attr:`.FormField.var` attributes
725 of the :attr:`.BaseGateway.REGISTRATION_FIELDS` iterable.
726 This dict can be modified and will be accessible as the ``legacy_module_data``
727 of the
729 :return : A dict that will be stored as the persistent "legacy_module_data"
730 for this user. If you don't return anything here, the whole registration_form
731 content will be stored.
732 """
733 raise NotImplementedError
735 async def validate_two_factor_code(
736 self, user: GatewayUser, code: str
737 ) -> Optional[dict]:
738 """
739 Called when the user enters their 2FA code.
741 Should raise the appropriate :class:`slixmpp.exceptions.XMPPError`
742 if the login fails, and return successfully otherwise.
744 Only used when :attr:`REGISTRATION_TYPE` is
745 :attr:`.RegistrationType.TWO_FACTOR_CODE`.
747 :param user: The :class:`.GatewayUser` whose registration is pending
748 Use their :attr:`.GatewayUser.bare_jid` and/or
749 :attr:`.registration_form` attributes to get what you need.
750 :param code: The code they entered, either via "chatbot" message or
751 adhoc command
753 :return : A dict which keys and values will be added to the persistent "legacy_module_data"
754 for this user.
755 """
756 raise NotImplementedError
758 async def get_qr_text(self, user: GatewayUser) -> str:
759 """
760 This is where slidge gets the QR code content for the QR-based
761 registration process. It will turn it into a QR code image and send it
762 to the not-yet-fully-registered :class:`.GatewayUser`.
764 Only used in when :attr:`BaseGateway.REGISTRATION_TYPE` is
765 :attr:`.RegistrationType.QRCODE`.
767 :param user: The :class:`.GatewayUser` whose registration is pending
768 Use their :attr:`.GatewayUser.bare_jid` and/or
769 :attr:`.registration_form` attributes to get what you need.
770 """
771 raise NotImplementedError
773 async def confirm_qr(
774 self,
775 user_bare_jid: str,
776 exception: Optional[Exception] = None,
777 legacy_data: Optional[dict] = None,
778 ):
779 """
780 This method is meant to be called to finalize QR code-based registration
781 flows, once the legacy service confirms the QR flashing.
783 Only used in when :attr:`BaseGateway.REGISTRATION_TYPE` is
784 :attr:`.RegistrationType.QRCODE`.
786 :param user_bare_jid: The bare JID of the almost-registered
787 :class:`GatewayUser` instance
788 :param exception: Optionally, an XMPPError to be raised to **not** confirm
789 QR code flashing.
790 :param legacy_data: dict which keys and values will be added to the persistent
791 "legacy_module_data" for this user.
792 """
793 fut = self.qr_pending_registrations[user_bare_jid]
794 if exception is None:
795 fut.set_result(legacy_data)
796 else:
797 fut.set_exception(exception)
799 async def unregister_user(self, user: GatewayUser):
800 self.send_presence(
801 pshow="busy", pstatus="You are not registered to this gateway anymore."
802 )
803 await self.xmpp.plugin["xep_0077"].api["user_remove"](None, None, user.jid)
804 await self.xmpp.session_cls.kill_by_jid(user.jid)
806 async def unregister(self, user: GatewayUser):
807 """
808 Optionally override this if you need to clean additional
809 stuff after a user has been removed from the persistent user store.
811 By default, this just calls :meth:`BaseSession.logout`.
813 :param user:
814 """
815 session = self.get_session_from_user(user)
816 try:
817 await session.logout()
818 except NotImplementedError:
819 pass
821 async def input(
822 self, jid: JID, text=None, mtype: MessageTypes = "chat", **msg_kwargs
823 ) -> str:
824 """
825 Request arbitrary user input using a simple chat message, and await the result.
827 You shouldn't need to call this directly bust instead use
828 :meth:`.BaseSession.input` to directly target a user.
830 :param jid: The JID we want input from
831 :param text: A prompt to display for the user
832 :param mtype: Message type
833 :return: The user's reply
834 """
835 return await self.__chat_commands_handler.input(jid, text, mtype, **msg_kwargs)
837 async def send_qr(self, text: str, **msg_kwargs):
838 """
839 Sends a QR Code to a JID
841 You shouldn't need to call directly bust instead use
842 :meth:`.BaseSession.send_qr` to directly target a user.
844 :param text: The text that will be converted to a QR Code
845 :param msg_kwargs: Optional additional arguments to pass to
846 :meth:`.BaseGateway.send_file`, such as the recipient of the QR,
847 code
848 """
849 qr = qrcode.make(text)
850 with tempfile.NamedTemporaryFile(
851 suffix=".png", delete=config.NO_UPLOAD_METHOD != "move"
852 ) as f:
853 qr.save(f.name)
854 await self.send_file(f.name, **msg_kwargs)
856 def shutdown(self) -> list[asyncio.Task]:
857 # """
858 # Called by the slidge entrypoint on normal exit.
859 #
860 # Sends offline presences from all contacts of all user sessions and from
861 # the gateway component itself.
862 # No need to call this manually, :func:`slidge.__main__.main` should take care of it.
863 # """
864 log.debug("Shutting down")
865 tasks = []
866 for user in self.store.users.get_all():
867 tasks.append(self.session_cls.from_jid(user.jid).shutdown())
868 self.send_presence(ptype="unavailable", pto=user.jid)
869 return tasks
872SLIXMPP_PLUGINS = [
873 "link_preview", # https://wiki.soprani.ca/CheogramApp/LinkPreviews
874 "xep_0030", # Service discovery
875 "xep_0045", # Multi-User Chat
876 "xep_0050", # Adhoc commands
877 "xep_0054", # VCard-temp (for MUC avatars)
878 "xep_0055", # Jabber search
879 "xep_0059", # Result Set Management
880 "xep_0066", # Out of Band Data
881 "xep_0071", # XHTML-IM (for stickers and custom emojis maybe later)
882 "xep_0077", # In-band registration
883 "xep_0084", # User Avatar
884 "xep_0085", # Chat state notifications
885 "xep_0100", # Gateway interaction
886 "xep_0106", # JID Escaping
887 "xep_0115", # Entity capabilities
888 "xep_0122", # Data Forms Validation
889 "xep_0153", # vCard-Based Avatars (for MUC avatars)
890 "xep_0172", # User nickname
891 "xep_0184", # Message Delivery Receipts
892 "xep_0199", # XMPP Ping
893 "xep_0221", # Data Forms Media Element
894 "xep_0231", # Bits of Binary (for stickers and custom emojis maybe later)
895 "xep_0249", # Direct MUC Invitations
896 "xep_0264", # Jingle Content Thumbnails
897 "xep_0280", # Carbons
898 "xep_0292_provider", # VCard4
899 "xep_0308", # Last message correction
900 "xep_0313", # Message Archive Management
901 "xep_0317", # Hats
902 "xep_0319", # Last User Interaction in Presence
903 "xep_0333", # Chat markers
904 "xep_0334", # Message Processing Hints
905 "xep_0356", # Privileged Entity
906 "xep_0356_old", # Privileged Entity (old namespace)
907 "xep_0363", # HTTP file upload
908 "xep_0385", # Stateless in-line media sharing
909 "xep_0402", # PEP Native Bookmarks
910 "xep_0421", # Anonymous unique occupant identifiers for MUCs
911 "xep_0424", # Message retraction
912 "xep_0425", # Message moderation
913 "xep_0444", # Message reactions
914 "xep_0447", # Stateless File Sharing
915 "xep_0461", # Message replies
916 "xep_0490", # Message Displayed Synchronization
917]
919LOG_STRIP_ELEMENTS = ["data", "binval"]
921log = logging.getLogger(__name__)