slidge 0.1.0__py3-none-any.whl

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