slidge 0.1.0b2__py3-none-any.whl → 0.1.1__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (155) hide show
  1. slidge/__init__.py +55 -31
  2. slidge/__main__.py +118 -116
  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 +2 -0
  15. slidge/core/cache.py +183 -0
  16. slidge/core/config.py +216 -0
  17. slidge/core/gateway/__init__.py +3 -0
  18. slidge/core/gateway/base.py +895 -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 +789 -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 +282 -116
  41. slidge/core/session.py +595 -372
  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 +458 -0
  46. slidge/group/room.py +1103 -0
  47. slidge/migration.py +18 -0
  48. slidge/slixfix/__init__.py +68 -0
  49. slidge/{util/xep_0084 → slixfix/link_preview}/__init__.py +3 -5
  50. slidge/slixfix/link_preview/link_preview.py +17 -0
  51. slidge/slixfix/link_preview/stanza.py +99 -0
  52. slidge/slixfix/roster.py +60 -0
  53. slidge/{util → slixfix}/xep_0077/register.py +14 -2
  54. slidge/slixfix/xep_0077/stanza.py +104 -0
  55. slidge/{util → slixfix}/xep_0100/gateway.py +25 -15
  56. slidge/slixfix/xep_0100/stanza.py +9 -0
  57. slidge/slixfix/xep_0153/__init__.py +10 -0
  58. slidge/slixfix/xep_0153/stanza.py +25 -0
  59. slidge/slixfix/xep_0153/vcard_avatar.py +23 -0
  60. slidge/slixfix/xep_0264/__init__.py +5 -0
  61. slidge/slixfix/xep_0264/stanza.py +36 -0
  62. slidge/slixfix/xep_0264/thumbnail.py +23 -0
  63. slidge/slixfix/xep_0292/__init__.py +5 -0
  64. slidge/slixfix/xep_0292/vcard4.py +100 -0
  65. slidge/slixfix/xep_0313/__init__.py +12 -0
  66. slidge/slixfix/xep_0313/mam.py +262 -0
  67. slidge/slixfix/xep_0313/stanza.py +359 -0
  68. slidge/slixfix/xep_0317/__init__.py +5 -0
  69. slidge/slixfix/xep_0317/hats.py +17 -0
  70. slidge/slixfix/xep_0317/stanza.py +28 -0
  71. slidge/{util → slixfix}/xep_0356_old/privilege.py +9 -7
  72. slidge/slixfix/xep_0424/__init__.py +9 -0
  73. slidge/slixfix/xep_0424/retraction.py +77 -0
  74. slidge/slixfix/xep_0424/stanza.py +28 -0
  75. slidge/slixfix/xep_0490/__init__.py +8 -0
  76. slidge/slixfix/xep_0490/mds.py +47 -0
  77. slidge/slixfix/xep_0490/stanza.py +17 -0
  78. slidge/util/__init__.py +4 -6
  79. slidge/util/archive_msg.py +61 -0
  80. slidge/util/conf.py +206 -0
  81. slidge/util/db.py +57 -76
  82. slidge/util/schema.sql +126 -0
  83. slidge/util/sql.py +508 -0
  84. slidge/util/test.py +215 -25
  85. slidge/util/types.py +177 -4
  86. slidge/util/util.py +225 -59
  87. slidge-0.1.1.dist-info/METADATA +110 -0
  88. slidge-0.1.1.dist-info/RECORD +96 -0
  89. {slidge-0.1.0b2.dist-info → slidge-0.1.1.dist-info}/WHEEL +1 -1
  90. slidge/core/contact.py +0 -891
  91. slidge/core/gateway.py +0 -916
  92. slidge/plugins/discord/__init__.py +0 -90
  93. slidge/plugins/discord/client.py +0 -108
  94. slidge/plugins/discord/session.py +0 -162
  95. slidge/plugins/dummy.py +0 -203
  96. slidge/plugins/facebook.py +0 -493
  97. slidge/plugins/hackernews.py +0 -213
  98. slidge/plugins/mattermost/__init__.py +0 -1
  99. slidge/plugins/mattermost/api.py +0 -280
  100. slidge/plugins/mattermost/gateway.py +0 -365
  101. slidge/plugins/mattermost/websocket.py +0 -252
  102. slidge/plugins/signal/__init__.py +0 -3
  103. slidge/plugins/signal/contact.py +0 -106
  104. slidge/plugins/signal/gateway.py +0 -282
  105. slidge/plugins/signal/session.py +0 -448
  106. slidge/plugins/signal/txt.py +0 -53
  107. slidge/plugins/skype.py +0 -325
  108. slidge/plugins/steam.py +0 -310
  109. slidge/plugins/telegram/__init__.py +0 -5
  110. slidge/plugins/telegram/client.py +0 -228
  111. slidge/plugins/telegram/config.py +0 -12
  112. slidge/plugins/telegram/contact.py +0 -176
  113. slidge/plugins/telegram/gateway.py +0 -150
  114. slidge/plugins/telegram/session.py +0 -256
  115. slidge/util/xep_0030/__init__.py +0 -13
  116. slidge/util/xep_0030/disco.py +0 -811
  117. slidge/util/xep_0030/stanza/__init__.py +0 -7
  118. slidge/util/xep_0030/stanza/info.py +0 -270
  119. slidge/util/xep_0030/stanza/items.py +0 -147
  120. slidge/util/xep_0030/static.py +0 -467
  121. slidge/util/xep_0055/__init__.py +0 -5
  122. slidge/util/xep_0055/search.py +0 -75
  123. slidge/util/xep_0055/stanza.py +0 -10
  124. slidge/util/xep_0077/stanza.py +0 -71
  125. slidge/util/xep_0084/avatar.py +0 -137
  126. slidge/util/xep_0084/stanza.py +0 -104
  127. slidge/util/xep_0115/__init__.py +0 -12
  128. slidge/util/xep_0115/caps.py +0 -379
  129. slidge/util/xep_0115/stanza.py +0 -16
  130. slidge/util/xep_0115/static.py +0 -137
  131. slidge/util/xep_0292/__init__.py +0 -1
  132. slidge/util/xep_0292/stanza.py +0 -167
  133. slidge/util/xep_0292/vcard4.py +0 -75
  134. slidge/util/xep_0333/__init__.py +0 -10
  135. slidge/util/xep_0333/markers.py +0 -96
  136. slidge/util/xep_0333/stanza.py +0 -34
  137. slidge/util/xep_0356/__init__.py +0 -7
  138. slidge/util/xep_0356/permissions.py +0 -35
  139. slidge/util/xep_0356/privilege.py +0 -160
  140. slidge/util/xep_0356/stanza.py +0 -44
  141. slidge/util/xep_0363/__init__.py +0 -16
  142. slidge/util/xep_0363/http_upload.py +0 -215
  143. slidge/util/xep_0363/stanza.py +0 -46
  144. slidge/util/xep_0461/__init__.py +0 -6
  145. slidge/util/xep_0461/reply.py +0 -48
  146. slidge/util/xep_0461/stanza.py +0 -47
  147. slidge-0.1.0b2.dist-info/METADATA +0 -171
  148. slidge-0.1.0b2.dist-info/RECORD +0 -81
  149. /slidge/{plugins/__init__.py → py.typed} +0 -0
  150. /slidge/{util → slixfix}/xep_0077/__init__.py +0 -0
  151. /slidge/{util → slixfix}/xep_0100/__init__.py +0 -0
  152. /slidge/{util → slixfix}/xep_0356_old/__init__.py +0 -0
  153. /slidge/{util → slixfix}/xep_0356_old/stanza.py +0 -0
  154. {slidge-0.1.0b2.dist-info → slidge-0.1.1.dist-info}/LICENSE +0 -0
  155. {slidge-0.1.0b2.dist-info → slidge-0.1.1.dist-info}/entry_points.txt +0 -0
slidge/core/gateway.py DELETED
@@ -1,916 +0,0 @@
1
- """
2
- This module extends slixmpp.ComponentXMPP to make writing new LegacyClients easier
3
- """
4
- import asyncio
5
- import logging
6
- import re
7
- import tempfile
8
- from asyncio import Future
9
- from datetime import timedelta
10
- from pathlib import Path
11
- from typing import Any, Generic, Iterable, Optional, Sequence, Type, TypeVar
12
-
13
- import qrcode
14
- from slixmpp import JID, ComponentXMPP, Iq, Message
15
- from slixmpp.exceptions import IqError, IqTimeout, XMPPError
16
- from slixmpp.types import MessageTypes
17
-
18
- from ..util import ABCSubclassableOnceAtMost, FormField
19
- from ..util.db import GatewayUser, RosterBackend, user_store
20
- from ..util.types import AvatarType
21
- from ..util.xep_0292.vcard4 import VCard4Provider
22
- from ..util.xep_0363 import FileUploadError
23
- from .pubsub import PubSubComponent
24
- from .session import BaseSession, SessionType
25
-
26
-
27
- class BaseGateway(
28
- Generic[SessionType], ComponentXMPP, metaclass=ABCSubclassableOnceAtMost
29
- ):
30
- """
31
- Must be subclassed by a plugin to set up various aspects of the XMPP
32
- component behaviour, such as its display name or its registration process.
33
-
34
- On slidge launch, a singleton is instantiated, and it will be made available
35
- to public classes such :class:`.LegacyContact` or :class:`.BaseSession` as the
36
- ``.xmpp`` attribute.
37
- Since it inherits from :class:`slixmpp.componentxmpp.ComponentXMPP`, this gives you a hand
38
- on low-level XMPP interactions via slixmpp plugins, e.g.:
39
-
40
- .. code-block:: python
41
-
42
- self.send_presence(
43
- pfrom="somebody@component.example.com",
44
- pto="someonwelse@anotherexample.com",
45
- )
46
-
47
- However, you should not need to do so often since the classes of the plugin
48
- API provides higher level abstractions around most commonly needed use-cases, such
49
- as sending messages, or displaying a custom status.
50
-
51
- """
52
-
53
- REGISTRATION_FIELDS: Iterable[FormField] = [
54
- FormField(var="username", label="User name", required=True),
55
- FormField(var="password", label="Password", required=True, private=True),
56
- ]
57
- """
58
- Iterable of fields presented to the gateway user when registering using :xep:`0077`
59
- `extended <https://xmpp.org/extensions/xep-0077.html#extensibility>`_ by :xep:`0004`.
60
- """
61
- REGISTRATION_INSTRUCTIONS: str = "Enter your credentials"
62
- """
63
- The text presented to a user that wants to register (or modify) their legacy account
64
- configuration.
65
- """
66
-
67
- COMPONENT_NAME: str = NotImplemented
68
- """Name of the component, as seen in service discovery by XMPP clients"""
69
- COMPONENT_TYPE: Optional[str] = ""
70
- """Type of the gateway, should ideally follow https://xmpp.org/registrar/disco-categories.html"""
71
- COMPONENT_AVATAR: Optional[AvatarType] = None
72
- """
73
- Path, bytes or URL used by the component as an avatar.
74
- """
75
-
76
- ROSTER_GROUP: str = "slidge"
77
- """
78
- Roster entries added by the plugin in the user's roster will be part of the group specified here.
79
- """
80
-
81
- SEARCH_FIELDS: Sequence[FormField] = [
82
- FormField(var="first", label="First name", required=True),
83
- FormField(var="last", label="Last name", required=True),
84
- FormField(var="phone", label="Last name", required=False),
85
- ]
86
- """
87
- Fields used for searching items via the component, through :xep:`0055` (jabber search).
88
- A common use case is to allow users to search for legacy contacts by something else than
89
- their usernames, eg their phone number.
90
-
91
- Plugins should implement search by overriding :meth:`.BaseSession.search`, effectively
92
- restricting search to registered users by default.
93
- """
94
- SEARCH_TITLE: str = "Search for legacy contacts"
95
- """
96
- Title of the search form.
97
- """
98
- SEARCH_INSTRUCTIONS: str = ""
99
- """
100
- Instructions of the search form.
101
- """
102
-
103
- _BASE_CHAT_COMMANDS = {
104
- "find": "_chat_command_search",
105
- "help": "_chat_command_help",
106
- "register": "_chat_command_register",
107
- "contacts": "_chat_command_list_contacts",
108
- }
109
- CHAT_COMMANDS: dict[str, str] = {}
110
- """
111
- Keys of this dict can be used to trigger a command by a simple chat message to the gateway
112
- component. Extra words after the key are passed as *args to the handler. Values of the dict
113
- are strings, and handlers are resolved using ``getattr()`` on the :class:`.BaseGateway`
114
- instance.
115
-
116
- Handlers are coroutines with following signature:
117
-
118
- .. code-block::python
119
-
120
- async def _chat_command_xxx(*args, msg: Message, session: Optional[Session] = None)
121
- ...
122
-
123
- The original :class:`slixmpp.stanza.Message` is also passed to the handler as the
124
- msg kwarg. If the command comes from a registered gateway user, its session attribute is also
125
- passed to the handler.
126
- """
127
-
128
- WELCOME_MESSAGE = (
129
- "Thank you for registering. Type 'help' to list the available commands, "
130
- "or just start messaging away!"
131
- )
132
- """
133
- A welcome message displayed to users on registration.
134
- This is useful notably for clients that don't consider component JIDs as a valid recipient in their UI,
135
- yet still open a functional chat window on incoming messages from components.
136
- """
137
-
138
- def __init__(self, args):
139
- """
140
-
141
- :param args: CLI arguments parsed by :func:`.slidge.__main__.get_parser`
142
- """
143
- super().__init__(
144
- args.jid,
145
- args.secret,
146
- args.server,
147
- args.port,
148
- plugin_whitelist=SLIXMPP_PLUGINS,
149
- plugin_config={
150
- "xep_0077": {
151
- "form_fields": None,
152
- "form_instructions": self.REGISTRATION_INSTRUCTIONS,
153
- },
154
- "xep_0100": {
155
- "component_name": self.COMPONENT_NAME,
156
- "user_store": user_store,
157
- "type": self.COMPONENT_TYPE,
158
- },
159
- "xep_0184": {
160
- "auto_ack": False,
161
- "auto_request": True,
162
- },
163
- "xep_0363": {
164
- "upload_service": args.upload_service,
165
- },
166
- },
167
- )
168
- self.loop.set_exception_handler(self.__exception_handler)
169
- self.has_crashed = False
170
-
171
- self.home_dir = Path(args.home_dir)
172
- self._jid_validator = re.compile(args.user_jid_validator)
173
- self._config = args
174
- self.no_roster_push = args.no_roster_push
175
- self.upload_requester = args.upload_requester or self.boundjid.bare
176
- self.ignore_delay_threshold = timedelta(seconds=args.ignore_delay_threshold)
177
-
178
- self._session_cls: Type[SessionType] = BaseSession.get_unique_subclass()
179
- self._session_cls.xmpp = self
180
-
181
- self._get_session_from_stanza = self._session_cls.from_stanza
182
- self._get_session_from_user = self._session_cls.from_user
183
- self.register_plugins()
184
- self.__register_slixmpp_api()
185
- self.__register_handlers()
186
- self._input_futures: dict[str, Future] = {}
187
-
188
- self._chat_commands = {
189
- k: getattr(self, v)
190
- for k, v in (self._BASE_CHAT_COMMANDS | self.CHAT_COMMANDS).items()
191
- }
192
-
193
- self.register_plugin("pubsub", {"component_name": self.COMPONENT_NAME})
194
- self.pubsub: PubSubComponent = self["pubsub"]
195
- self.vcard: VCard4Provider = self["xep_0292_provider"]
196
-
197
- def __exception_handler(self, loop: asyncio.AbstractEventLoop, context):
198
- """
199
- Called when a task created by loop.create_task() raises an Exception
200
-
201
- :param loop:
202
- :param context:
203
- :return:
204
- """
205
- log.debug("CONTEXT: %s", context)
206
- exc = context.get("exception")
207
- if exc is None:
208
- log.warning("No exception in this context: %s", context)
209
- elif isinstance(exc, SystemExit):
210
- log.debug("SystemExit called in an asyncio task")
211
- else:
212
- log.exception("Crash in an asyncio task: %s", context)
213
- self.has_crashed = True
214
- loop.stop()
215
-
216
- def exception(self, exception: Exception):
217
- """
218
- Called when a task created by slixmpp's internal (eg, on slix events) raises an Exception.
219
-
220
- Stop the event loop and exit on unhandled exception.
221
-
222
- The default :class:slixmpp.basexmpp.BaseXMPP` behaviour is just to
223
- log the exception, but we want to avoid undefined behaviour.
224
-
225
- :param exception: An unhandled :class:`Exception` object.
226
- """
227
- if isinstance(exception, IqError):
228
- iq = exception.iq
229
- log.error("%s: %s", iq["error"]["condition"], iq["error"]["text"])
230
- log.warning("You should catch IqError exceptions")
231
- elif isinstance(exception, IqTimeout):
232
- iq = exception.iq
233
- log.error("Request timed out: %s", iq)
234
- log.warning("You should catch IqTimeout exceptions")
235
- elif isinstance(exception, SyntaxError):
236
- # Hide stream parsing errors that occur when the
237
- # stream is disconnected (they've been handled, we
238
- # don't need to make a mess in the logs).
239
- pass
240
- else:
241
- self.loop.stop()
242
- exit(1)
243
-
244
- def __register_slixmpp_api(self):
245
- self["xep_0077"].api.register(
246
- user_store.get,
247
- "user_get",
248
- )
249
- self["xep_0077"].api.register(
250
- user_store.remove,
251
- "user_remove",
252
- )
253
- self["xep_0077"].api.register(
254
- self._make_registration_form,
255
- "make_registration_form",
256
- )
257
- self["xep_0077"].api.register(self._user_validate, "user_validate")
258
- self["xep_0077"].api.register(self._user_modify, "user_modify")
259
-
260
- self["xep_0055"].api.register(self._search_get_form, "search_get_form")
261
- self["xep_0055"].api.register(self._search_query, "search_query")
262
-
263
- self.roster.set_backend(RosterBackend)
264
-
265
- def __register_handlers(self):
266
- self.add_event_handler("session_start", self.__on_session_start)
267
- self.add_event_handler("disconnected", self.connect)
268
- self.add_event_handler("gateway_message", self._on_gateway_message_private)
269
- self.add_event_handler("user_register", self._on_user_register)
270
- self.add_event_handler("user_unregister", self._on_user_unregister)
271
- get_session = self._get_session_from_stanza
272
-
273
- # fmt: off
274
- async def msg(m): await get_session(m).send_from_msg(m)
275
- async def disp(m): await get_session(m).displayed_from_msg(m)
276
- async def active(m): await get_session(m).active_from_msg(m)
277
- async def inactive(m): await get_session(m).inactive_from_msg(m)
278
- async def composing(m): await get_session(m).composing_from_msg(m)
279
- async def paused(m): await get_session(m).paused_from_msg(m)
280
- async def correct(m): await get_session(m).correct_from_msg(m)
281
- async def react(m): await get_session(m).react_from_msg(m)
282
- async def retract(m): await get_session(m).retract_from_msg(m)
283
- # fmt: on
284
-
285
- self.add_event_handler("legacy_message", msg)
286
- self.add_event_handler("marker_displayed", disp)
287
- self.add_event_handler("chatstate_active", active)
288
- self.add_event_handler("chatstate_inactive", inactive)
289
- self.add_event_handler("chatstate_composing", composing)
290
- self.add_event_handler("chatstate_paused", paused)
291
- self.add_event_handler("message_correction", correct)
292
- self.add_event_handler("reactions", react)
293
- self.add_event_handler("message_retract", retract)
294
-
295
- async def __on_session_start(self, event):
296
- log.debug("Gateway session start: %s", event)
297
-
298
- # prevents XMPP clients from considering the gateway as an HTTP upload
299
- disco = self.plugin["xep_0030"]
300
- await disco.del_feature(
301
- feature="urn:xmpp:http:upload:0", jid=self.boundjid.bare
302
- )
303
- await self.plugin["xep_0115"].update_caps(jid=self.boundjid.bare)
304
-
305
- self.__add_adhoc_commands()
306
- self.add_adhoc_commands()
307
- await self.pubsub.set_avatar(
308
- jid=self.boundjid.bare, avatar=self.COMPONENT_AVATAR
309
- )
310
-
311
- for user in user_store.get_all():
312
- # TODO: before this, we should check if the user has removed us from their roster
313
- # while we were offline and trigger unregister from there. Presence probe does not seem
314
- # to work in this case, there must be another way. privileged entity could be used
315
- # as last resort.
316
- await self["xep_0100"].add_component_to_roster(user.jid)
317
- self.send_presence(
318
- pto=user.bare_jid, ptype="probe"
319
- ) # ensure we get all resources for user
320
- session = self._session_cls.from_user(user)
321
- self.loop.create_task(self._login_wrap(session))
322
- for c in session.contacts:
323
- # we need to receive presences directed at the contacts, in order to
324
- # send pubsub events for their +notify features
325
- self.send_presence(pfrom=c.jid, pto=user.bare_jid, ptype="probe")
326
-
327
- log.info("Slidge has successfully started")
328
-
329
- @staticmethod
330
- async def _login_wrap(session: "SessionType"):
331
- session.send_gateway_status("Logging in…", show="dnd")
332
- try:
333
- status = await session.login()
334
- except Exception as e:
335
- log.warning(f"Login problem for %s: %r", session.user, e)
336
- session.send_gateway_status(f"Could not login: {e}", show="busy")
337
- session.send_gateway_message(
338
- f"You are not connected to this gateway! "
339
- f"Maybe this message will tell you why: {e}"
340
- )
341
- else:
342
- log.info(f"Login success for %s", session.user)
343
- if status is None:
344
- session.send_gateway_status("Logged in", show="chat")
345
- else:
346
- session.send_gateway_status(status, show="chat")
347
-
348
- def re_login(self, session: "SessionType"):
349
- async def w():
350
- await session.logout()
351
- await self._login_wrap(session)
352
-
353
- self.loop.create_task(w())
354
-
355
- def __add_adhoc_commands(self):
356
- # TODO: this should only be advertised to admins
357
- # Not a big deal since we need to check if 'from' is an admin in the handler
358
- # anyway, BUT it would be nice if this simply does not show up in the list
359
- # of available commands for regular users.
360
- self["xep_0050"].add_command(
361
- node="info", name="List registered users", handler=self._handle_info
362
- )
363
- self.plugin["xep_0050"].add_command(
364
- node="search", name="Search for contacts", handler=self._handle_search
365
- )
366
-
367
- def _handle_info(self, iq: Iq, session: dict[str, Any]):
368
- """
369
- List registered users for admins
370
- """
371
- if iq.get_from().bare not in self._config.admins:
372
- raise XMPPError("not-authorized")
373
- form = self["xep_0004"].make_form("result", "Component info")
374
- form.add_field(
375
- ftype="jid-multi",
376
- label="Users",
377
- value=[u.bare_jid for u in user_store.get_all()],
378
- )
379
-
380
- session["payload"] = form
381
- session["has_next"] = False
382
-
383
- return session
384
-
385
- async def _handle_search(self, iq: Iq, adhoc_session: dict[str, Any]):
386
- """
387
- Jabber search, but as an adhoc command (search form)
388
- """
389
- user = user_store.get_by_jid(iq.get_from())
390
- if user is None:
391
- raise XMPPError(
392
- "not-authorized", text="Search is only allowed for registered users"
393
- )
394
-
395
- session = self._get_session_from_stanza(iq)
396
-
397
- reply = await self._search_get_form(None, None, ifrom=iq.get_from(), iq=iq)
398
- adhoc_session["payload"] = reply["search"]["form"]
399
- adhoc_session["next"] = self._handle_search2
400
- adhoc_session["has_next"] = True
401
- adhoc_session["session"] = session
402
-
403
- return adhoc_session
404
-
405
- async def _handle_search2(self, form, adhoc_session: dict[str, Any]):
406
- """
407
- Jabber search, but as an adhoc command (results)
408
- """
409
-
410
- search_results = await adhoc_session["session"].search(form.get_values())
411
-
412
- form = self.plugin["xep_0004"].make_form("result", "Contact search results")
413
- for field in search_results.fields:
414
- form.add_reported(field.var, label=field.label, type=field.type)
415
- for item in search_results.items:
416
- form.add_item(item)
417
-
418
- adhoc_session["next"] = None
419
- adhoc_session["has_next"] = False
420
- adhoc_session["payload"] = form
421
-
422
- return adhoc_session
423
-
424
- async def _make_registration_form(self, _jid, _node, _ifrom, iq: Iq):
425
- if not self._jid_validator.match(iq.get_from().bare):
426
- raise XMPPError(condition="not-allowed")
427
-
428
- reg = iq["register"]
429
- user = user_store.get_by_stanza(iq)
430
- log.debug("User found: %s", user)
431
-
432
- form = reg["form"]
433
- form.add_field(
434
- "FORM_TYPE",
435
- ftype="hidden",
436
- value="jabber:iq:register",
437
- )
438
- form["title"] = f"Registration to '{self.COMPONENT_NAME}'"
439
- form["instructions"] = self.REGISTRATION_INSTRUCTIONS
440
-
441
- if user is not None:
442
- reg["registered"] = False
443
- form.add_field(
444
- "remove",
445
- label="Remove my registration",
446
- required=True,
447
- ftype="boolean",
448
- value=False,
449
- )
450
-
451
- for field in self.REGISTRATION_FIELDS:
452
- if field.var in reg.interfaces:
453
- val = None if user is None else user.get(field.var)
454
- if val is None:
455
- reg.add_field(field.var)
456
- else:
457
- reg[field.var] = val
458
-
459
- reg["instructions"] = self.REGISTRATION_INSTRUCTIONS
460
-
461
- for field in self.REGISTRATION_FIELDS:
462
- form.add_field(
463
- field.var,
464
- label=field.label,
465
- required=field.required,
466
- ftype=field.type,
467
- options=field.options,
468
- value=field.value if user is None else user.get(field.var, field.value),
469
- )
470
-
471
- reply = iq.reply()
472
- reply.set_payload(reg)
473
- return reply
474
-
475
- async def _user_prevalidate(self, ifrom: JID, form_dict: dict[str, Optional[str]]):
476
- """
477
- Pre validate a registration form using the content of self.REGISTRATION_FIELDS
478
- before passing it to the plugin custom validation logic.
479
- """
480
- for field in self.REGISTRATION_FIELDS:
481
- if field.required and not form_dict.get(field.var):
482
- raise ValueError(f"Missing field: '{field.label}'")
483
-
484
- await self.validate(ifrom, form_dict)
485
-
486
- async def _user_validate(
487
- self, _gateway_jid, _node, ifrom: JID, form_dict: dict[str, Optional[str]]
488
- ):
489
- """
490
- SliXMPP internal API stuff
491
- """
492
- log.debug("User validate: %s", ifrom.bare)
493
- if not self._jid_validator.match(ifrom.bare):
494
- raise XMPPError(condition="not-allowed")
495
- await self._user_prevalidate(ifrom, form_dict)
496
- log.info("New user: %s", ifrom.bare)
497
- user_store.add(ifrom, form_dict)
498
-
499
- async def _user_modify(
500
- self, _gateway_jid, _node, ifrom: JID, form_dict: dict[str, Optional[str]]
501
- ):
502
- """
503
- SliXMPP internal API stuff
504
- """
505
- user = user_store.get_by_jid(ifrom)
506
- log.debug("Modify user: %s", user)
507
- await self._user_prevalidate(ifrom, form_dict)
508
- user_store.add(ifrom, form_dict)
509
-
510
- async def _on_user_register(self, iq: Iq):
511
- session = self._get_session_from_stanza(iq)
512
- for jid in self._config.admins:
513
- self.send_message(
514
- mto=jid,
515
- mbody=f"{iq.get_from()} has registered",
516
- mtype="headline",
517
- mfrom=self.boundjid.bare,
518
- )
519
- session.send_gateway_message(self.WELCOME_MESSAGE)
520
- await session.login()
521
-
522
- async def _on_user_unregister(self, iq: Iq):
523
- # Mypy: "Type[SessionType?]" has no attribute "kill_by_jid"
524
- # I don't understand why ^ this question mark...
525
- kill = self._session_cls.kill_by_jid # type: ignore
526
- await kill(iq.get_from())
527
-
528
- async def _search_get_form(self, _gateway_jid, _node, ifrom: JID, iq: Iq):
529
- """
530
- Prepare the search form using self.SEARCH_FIELDS
531
- """
532
- user = user_store.get_by_jid(ifrom)
533
- if user is None:
534
- raise XMPPError(text="Search is only allowed for registered users")
535
-
536
- reply = iq.reply()
537
- form = reply["search"]["form"]
538
- form["title"] = self.SEARCH_TITLE
539
- form["instructions"] = self.SEARCH_INSTRUCTIONS
540
- for field in self.SEARCH_FIELDS:
541
- form.add_field(**field.dict())
542
- return reply
543
-
544
- async def _search_query(self, _gateway_jid, _node, ifrom: JID, iq: Iq):
545
- """
546
- Handles a search request
547
- """
548
- user = user_store.get_by_jid(ifrom)
549
- if user is None:
550
- raise XMPPError(text="Search is only allowed for registered users")
551
-
552
- result = await self._get_session_from_stanza(iq).search(
553
- iq["search"]["form"].get_values()
554
- )
555
-
556
- if not result:
557
- raise XMPPError("item-not-found", text="Nothing was found")
558
-
559
- reply = iq.reply()
560
- form = reply["search"]["form"]
561
- for field in result.fields:
562
- form.add_reported(field.var, label=field.label, type=field.type)
563
- for item in result.items:
564
- form.add_item(item)
565
- return reply
566
-
567
- async def _chat_command_search(
568
- self, *args, msg: Message, session: Optional[SessionType] = None
569
- ):
570
- if session is None:
571
- msg.reply("Register to the gateway first!")
572
- return
573
-
574
- search_form = {}
575
- diff = len(args) - len(self.SEARCH_FIELDS)
576
-
577
- if diff > 0:
578
- session.send_gateway_message("Too many parameters!")
579
- return
580
-
581
- for field, arg in zip(self.SEARCH_FIELDS, args):
582
- search_form[field.var] = arg
583
-
584
- if diff < 0:
585
- for field in self.SEARCH_FIELDS[diff:]:
586
- if not field.required:
587
- continue
588
- search_form[field.var] = await session.input(
589
- (field.label or field.var) + "?"
590
- )
591
-
592
- results = await session.search(search_form)
593
- if results is None:
594
- session.send_gateway_message("No results!")
595
- return
596
-
597
- result_fields = results.fields
598
- for result in results.items:
599
- text = ""
600
- for f in result_fields:
601
- if f.type == "jid-single":
602
- text += f"xmpp:{result[f.var]}\n"
603
- else:
604
- text += f"{f.label}: {result[f.var]}\n"
605
- session.send_gateway_message(text)
606
-
607
- async def _chat_command_help(
608
- self, *_args, msg: Message, session: Optional[SessionType]
609
- ):
610
- if session is None:
611
- msg.reply("Register to the gateway first!").send()
612
- else:
613
- t = "|".join(
614
- x
615
- for x in self._chat_commands.keys()
616
- if not x not in ("register", "help")
617
- )
618
- log.debug("In help: %s", t)
619
- msg.reply(f"Available commands: {t}").send()
620
-
621
- @staticmethod
622
- async def _chat_command_list_contacts(
623
- *_args, msg: Message, session: Optional[SessionType]
624
- ):
625
- if session is None:
626
- msg.reply("Register to the gateway first!").send()
627
- else:
628
- contacts = sorted(
629
- session.contacts, key=lambda c: c.name.casefold() if c.name else ""
630
- )
631
- t = "\n".join(f"{c.name}: xmpp:{c.jid.bare}" for c in contacts)
632
- msg.reply(t).send()
633
-
634
- async def _chat_command_register(
635
- self, *args, msg: Message, session: Optional[SessionType]
636
- ):
637
- if session is not None:
638
- msg.reply("You are already registered to this gateway").send()
639
- return
640
-
641
- jid = msg.get_from()
642
-
643
- if not self._jid_validator.match(jid.bare):
644
- msg.reply("You are not allowed to register to this gateway").send()
645
- return
646
-
647
- form: dict[str, Optional[str]] = {}
648
- for field in self.REGISTRATION_FIELDS:
649
- text = field.label or field.var
650
- if field.value != "":
651
- text += f" (default: '{field.value}')"
652
- if not field.required:
653
- text += " (optional, reply with '.' to skip)"
654
- if (options := field.options) is not None:
655
- for option in options:
656
- label = option["label"]
657
- value = option["value"]
658
- text += f"\n{label}: reply with '{value}'"
659
-
660
- while True:
661
- ans = await self.input(jid, text + "?")
662
- if ans == "." and not field.required:
663
- form[field.var] = None
664
- break
665
- else:
666
- if (options := field.options) is not None:
667
- valid_choices = [x["value"] for x in options]
668
- if ans not in valid_choices:
669
- continue
670
- form[field.var] = ans
671
- break
672
-
673
- try:
674
- await self.validate(jid, form)
675
- await self["xep_0077"].api["user_validate"](None, None, jid, form)
676
- except (ValueError, XMPPError) as e:
677
- msg.reply(f"Something went wrong: {e}").send()
678
- else:
679
- self.event("user_register", msg)
680
- msg.reply(f"Success!").send()
681
-
682
- def add_adhoc_commands(self):
683
- """
684
- Override this if you want to provide custom adhoc commands (:xep:`0050`)
685
- for your plugin, using :class:`slixmpp.plugins.xep_0050.XEP_0050`
686
-
687
- Basic example:
688
-
689
- .. code-block:python
690
-
691
- def add_adhoc_commands(self):
692
- self["xep_0050"].add_command(
693
- node="account_info",
694
- name="Account Information",
695
- handler=self.handle_account_info
696
- )
697
-
698
- async def handle_account_info(self, iq: Iq, adhoc_session: dict[str, Any]):
699
- # beware, 'adhoc_session' is not a slidge session!
700
- user = user_store.get_by_stanza(iq)
701
-
702
- if user is None:
703
- raise XMPPError("subscription-required")
704
-
705
- form = self["xep_0004"].make_form("result", "Account info")
706
- form.add_field(
707
- label="Credits",
708
- value=await FakeLegacyClient.get_credits(user.registration_form['username']),
709
- )
710
-
711
- adhoc_session["payload"] = form
712
- adhoc_session["has_next"] = False
713
-
714
- return session
715
- """
716
- pass
717
-
718
- def config(self, argv: list[str]):
719
- """
720
- Override this to access CLI args to configure the slidge plugin
721
-
722
- :param argv: CLI args that were not parsed by the slidge main entrypoint parser
723
- :func:`slidge.__main__.get_parser`
724
- """
725
- pass
726
-
727
- async def validate(
728
- self, user_jid: JID, registration_form: dict[str, Optional[str]]
729
- ):
730
- """
731
- Validate a registration form from a user.
732
-
733
- Since :xep:`0077` is pretty limited in terms of validation, it is OK to validate
734
- anything that looks good here and continue the legacy auth process via direct messages
735
- to the user (using :meth:`.BaseGateway.input` for instance)
736
-
737
- :param user_jid: JID of the user that has just registered
738
- :param registration_form: A dict where keys are the :attr:`.FormField.var` attributes
739
- of the :attr:`.BaseGateway.REGISTRATION_FIELDS` iterable
740
- """
741
- pass
742
-
743
- async def unregister(self, user: GatewayUser):
744
- """
745
- Optionally override this if you need to clean additional
746
- stuff after a user has been removed from the permanent user_store.
747
-
748
- :param user:
749
- :return:
750
- """
751
- pass
752
-
753
- async def _on_gateway_message_private(self, msg: Message):
754
- """
755
- Called when an XMPP user (not necessarily registered as a gateway user) sends a direct message to
756
- the gateway.
757
-
758
- If you override this and still want :meth:`.BaseGateway.input` to work, make sure to include the try/except part.
759
-
760
- :param msg: Message sent by the XMPP user
761
- """
762
- try:
763
- f = self._input_futures.pop(msg.get_from().bare)
764
- except KeyError:
765
- text = msg["body"]
766
- command, *rest = text.split(" ")
767
-
768
- user = user_store.get_by_stanza(msg)
769
- if user is None:
770
- session = None
771
- else:
772
- session = self._get_session_from_user(user)
773
-
774
- handler = self._chat_commands.get(command)
775
- if handler is None:
776
- await self.on_gateway_message(msg, session=session)
777
- else:
778
- log.debug("Chat command handler: %s", handler)
779
- await handler(*rest, msg=msg, session=session)
780
- else:
781
- f.set_result(msg["body"])
782
-
783
- @staticmethod
784
- async def on_gateway_message(msg: Message, session: Optional[SessionType] = None):
785
- """
786
- Called when the gateway component receives a direct gateway message.
787
-
788
- Can be used to implement bot like commands, especially in conjunction with
789
- :meth:`.BaseGateway.input`
790
-
791
- :param msg:
792
- :param session: If the message comes from a registered gateway user, their :.BaseSession:
793
- """
794
- if session is None:
795
- r = msg.reply(
796
- body="I got that, but I'm not doing anything with it. I don't even know you!"
797
- )
798
- else:
799
- r = msg.reply(body="What? Type 'help' for the list of available commands.")
800
- r["type"] = "chat"
801
- r.send()
802
-
803
- async def input(
804
- self, jid: JID, text=None, mtype: MessageTypes = "chat", **msg_kwargs
805
- ) -> str:
806
- """
807
- Request arbitrary user input using a simple chat message, and await the result.
808
-
809
- You shouldn't need to call directly bust instead user :meth:`.BaseSession.input`
810
- to directly target a user.
811
-
812
- NB: When using this, the next message that the user sent to the component will
813
- not be transmitted to :meth:`.BaseGateway.on_gateway_message`, but rather intercepted.
814
- Await the coroutine to get its content.
815
-
816
- :param jid: The JID we want input from
817
- :param text: A prompt to display for the user
818
- :param mtype: Message type
819
- :return: The user's reply
820
- """
821
- if text is not None:
822
- self.send_message(
823
- mto=jid, mbody=text, mtype=mtype, mfrom=self.boundjid.bare, **msg_kwargs
824
- )
825
- f = self.loop.create_future()
826
- self._input_futures[jid.bare] = f
827
- await f
828
- return f.result()
829
-
830
- async def send_file(self, filename: str, **msg_kwargs):
831
- """
832
- Upload a file using :xep:`0363` and send the link as out of band (:xep:`0066`)
833
- content in a message.
834
-
835
- :param filename:
836
- :param msg_kwargs:
837
- :return:
838
- """
839
- msg = self.make_message(**msg_kwargs)
840
- msg.set_from(self.boundjid.bare)
841
- try:
842
- url = await self["xep_0363"].upload_file(
843
- filename=filename, ifrom=self.upload_requester
844
- )
845
- except FileUploadError as e:
846
- log.warning(
847
- "Something is wrong with the upload service, see the traceback below"
848
- )
849
- log.exception(e)
850
- msg["body"] = (
851
- "I tried to send a file, but something went wrong. "
852
- "Tell your XMPP admin to check slidge logs."
853
- )
854
- msg.send()
855
- return
856
- msg["oob"]["url"] = url
857
- msg["body"] = url
858
- msg.send()
859
-
860
- async def send_qr(self, text: str, **msg_kwargs):
861
- """
862
- Sends a QR Code to a JID
863
-
864
- :param text: The text that will be converted to a QR Code
865
- :param msg_kwargs: Optional additional arguments to pass to :meth:`.BaseGateway.send_file`,
866
- such as the recipient of the QR code.
867
- """
868
- qr = qrcode.make(text)
869
- with tempfile.NamedTemporaryFile(suffix=".png") as f:
870
- qr.save(f.name)
871
- await self.send_file(f.name, **msg_kwargs)
872
-
873
- def shutdown(self):
874
- """
875
- Called by the slidge entrypoint on normal exit.
876
-
877
- Sends offline presences from all contacts of all user sessions and from
878
- the gateway component itself.
879
- No need to call this manually, :func:`slidge.__main__.main` should take care of it.
880
- """
881
- log.debug("Shutting down")
882
- for user in user_store.get_all():
883
- session = self._session_cls.from_jid(user.jid)
884
- for c in session.contacts:
885
- c.offline()
886
- self.loop.create_task(session.logout())
887
- self.send_presence(ptype="unavailable", pto=user.jid)
888
-
889
-
890
- GatewayType = TypeVar("GatewayType", bound=BaseGateway)
891
-
892
-
893
- SLIXMPP_PLUGINS = [
894
- "xep_0050", # Adhoc commands
895
- "xep_0055", # Jabber search
896
- "xep_0066", # Out of Band Data
897
- "xep_0077", # In-band registration
898
- "xep_0084", # User Avatar
899
- "xep_0085", # Chat state notifications
900
- "xep_0100", # Gateway interaction
901
- "xep_0115", # Entity capabilities
902
- "xep_0172", # User nickname
903
- "xep_0184", # Message Delivery Receipts
904
- "xep_0280", # Carbons
905
- "xep_0292_provider", # VCard4
906
- "xep_0308", # Last message correction
907
- "xep_0333", # Chat markers
908
- "xep_0334", # Message Processing Hints
909
- "xep_0356", # Privileged Entity
910
- "xep_0356_old", # Privileged Entity (old namespace)
911
- "xep_0363", # HTTP file upload
912
- "xep_0424", # Message retraction
913
- "xep_0444", # Message reactions
914
- "xep_0461", # Message replies
915
- ]
916
- log = logging.getLogger(__name__)