slidge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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__)