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,288 @@
1
+ # Handle slidge commands by exchanging chat messages with the gateway components.
2
+
3
+ # Ad-hoc methods should provide a better UX, but some clients do not support them,
4
+ # so this is mostly a fallback.
5
+ import asyncio
6
+ import functools
7
+ import logging
8
+ from typing import TYPE_CHECKING, Callable, Literal, Optional, Union, overload
9
+ from urllib.parse import quote as url_quote
10
+
11
+ from slixmpp import JID, CoroutineCallback, Message, StanzaPath
12
+ from slixmpp.exceptions import XMPPError
13
+ from slixmpp.types import JidStr, MessageTypes
14
+
15
+ from . import Command, CommandResponseType, Confirmation, Form, TableResult
16
+
17
+ if TYPE_CHECKING:
18
+ from ..core.gateway import BaseGateway
19
+
20
+
21
+ class ChatCommandProvider:
22
+ UNKNOWN = "Wut? I don't know that command: {}"
23
+
24
+ def __init__(self, xmpp: "BaseGateway"):
25
+ self.xmpp = xmpp
26
+ self._keywords = list[str]()
27
+ self._commands: dict[str, Command] = {}
28
+ self._input_futures = dict[str, asyncio.Future[str]]()
29
+ self.xmpp.register_handler(
30
+ CoroutineCallback(
31
+ "chat_command_handler",
32
+ StanzaPath(f"message@to={self.xmpp.boundjid.bare}"),
33
+ self._handle_message, # type: ignore
34
+ )
35
+ )
36
+
37
+ def register(self, command: Command):
38
+ """
39
+ Register a command to be used via chat messages with the gateway
40
+
41
+ Plugins should not call this, any class subclassing Command should be
42
+ automatically added by slidge core.
43
+
44
+ :param command: the new command
45
+ """
46
+ t = command.CHAT_COMMAND
47
+ if t in self._commands:
48
+ raise RuntimeError("There is already a command triggered by '%s'", t)
49
+ self._commands[t] = command
50
+
51
+ @overload
52
+ async def input(
53
+ self, jid: JidStr, text: Optional[str], blocking: Literal[False]
54
+ ) -> asyncio.Future[str]: ...
55
+
56
+ @overload
57
+ async def input(
58
+ self,
59
+ jid: JidStr,
60
+ text: Optional[str],
61
+ mtype: MessageTypes = ...,
62
+ blocking: Literal[True] = ...,
63
+ ) -> str: ...
64
+
65
+ async def input(
66
+ self,
67
+ jid,
68
+ text=None,
69
+ mtype="chat",
70
+ timeout=60,
71
+ blocking=True,
72
+ **msg_kwargs,
73
+ ):
74
+ """
75
+ Request arbitrary user input using a simple chat message, and await the result.
76
+
77
+ You shouldn't need to call directly bust instead use :meth:`.BaseSession.input`
78
+ to directly target a user.
79
+
80
+ NB: When using this, the next message that the user sent to the component will
81
+ not be transmitted to :meth:`.BaseGateway.on_gateway_message`, but rather intercepted.
82
+ Await the coroutine to get its content.
83
+
84
+ :param jid: The JID we want input from
85
+ :param text: A prompt to display for the user
86
+ :param mtype: Message type
87
+ :param timeout:
88
+ :param blocking: If set to False, timeout has no effect and an :class:`asyncio.Future`
89
+ is returned instead of a str
90
+ :return: The user's reply
91
+ """
92
+ jid = JID(jid)
93
+ if text is not None:
94
+ self.xmpp.send_message(
95
+ mto=jid,
96
+ mbody=text,
97
+ mtype=mtype,
98
+ mfrom=self.xmpp.boundjid.bare,
99
+ **msg_kwargs,
100
+ )
101
+ f = asyncio.get_event_loop().create_future()
102
+ self._input_futures[jid.bare] = f
103
+ if not blocking:
104
+ return f
105
+ try:
106
+ await asyncio.wait_for(f, timeout)
107
+ except asyncio.TimeoutError:
108
+ self.xmpp.send_message(
109
+ mto=jid,
110
+ mbody="You took too much time to reply",
111
+ mtype=mtype,
112
+ mfrom=self.xmpp.boundjid.bare,
113
+ )
114
+ del self._input_futures[jid.bare]
115
+ raise XMPPError("remote-server-timeout", "You took too much time to reply")
116
+
117
+ return f.result()
118
+
119
+ async def _handle_message(self, msg: Message):
120
+ if not msg["body"]:
121
+ return
122
+
123
+ if not msg.get_from().node:
124
+ return # ignore component and server messages
125
+
126
+ f = self._input_futures.pop(msg.get_from().bare, None)
127
+ if f is not None:
128
+ f.set_result(msg["body"])
129
+ return
130
+
131
+ c = msg["body"]
132
+ first_word, *rest = c.split(" ")
133
+ first_word = first_word.lower()
134
+
135
+ if first_word == "help":
136
+ return self._handle_help(msg, *rest)
137
+
138
+ mfrom = msg.get_from()
139
+
140
+ command = self._commands.get(first_word)
141
+ if command is None:
142
+ return self._not_found(msg, first_word)
143
+
144
+ try:
145
+ session = command.raise_if_not_authorized(mfrom)
146
+ except XMPPError as e:
147
+ reply = msg.reply()
148
+ reply["body"] = e.text
149
+ reply.send()
150
+ raise
151
+
152
+ result = await self.__wrap_handler(msg, command.run, session, mfrom, *rest)
153
+ self.xmpp.delivery_receipt.ack(msg)
154
+ return await self._handle_result(result, msg, session)
155
+
156
+ async def _handle_result(self, result: CommandResponseType, msg: Message, session):
157
+ if isinstance(result, str) or result is None:
158
+ reply = msg.reply()
159
+ reply["body"] = result or "End of command."
160
+ reply.send()
161
+ return
162
+
163
+ if isinstance(result, Form):
164
+ form_values = {}
165
+ for t in result.title, result.instructions:
166
+ if t:
167
+ msg.reply(t).send()
168
+ for f in result.fields:
169
+ if f.type == "fixed":
170
+ msg.reply(f"{f.label or f.var}: {f.value}").send()
171
+ else:
172
+ if f.type == "list-multi":
173
+ msg.reply(
174
+ "Multiple selection allowed, use a single space as a separator"
175
+ ).send()
176
+ if f.options:
177
+ for o in f.options:
178
+ msg.reply(f"{o['value']} -- {o['label']}").send()
179
+ if f.value:
180
+ msg.reply(f"Default: {f.value}").send()
181
+
182
+ ans = await self.xmpp.input(
183
+ msg.get_from(), (f.label or f.var) + "? (or 'abort')"
184
+ )
185
+ if ans.lower() == "abort":
186
+ return await self._handle_result(
187
+ "Command aborted", msg, session
188
+ )
189
+ if f.type.endswith("multi"):
190
+ form_values[f.var] = f.validate(ans.split(" "))
191
+ else:
192
+ form_values[f.var] = f.validate(ans)
193
+ result = await self.__wrap_handler(
194
+ msg,
195
+ result.handler,
196
+ form_values,
197
+ session,
198
+ msg.get_from(),
199
+ *result.handler_args,
200
+ **result.handler_kwargs,
201
+ )
202
+ return await self._handle_result(result, msg, session)
203
+
204
+ if isinstance(result, Confirmation):
205
+ yes_or_no = await self.input(msg.get_from(), result.prompt)
206
+ if not yes_or_no.lower().startswith("y"):
207
+ reply = msg.reply()
208
+ reply["body"] = "Canceled"
209
+ reply.send()
210
+ return
211
+ result = await self.__wrap_handler(
212
+ msg,
213
+ result.handler,
214
+ session,
215
+ msg.get_from(),
216
+ *result.handler_args,
217
+ **result.handler_kwargs,
218
+ )
219
+ return await self._handle_result(result, msg, session)
220
+
221
+ if isinstance(result, TableResult):
222
+ if len(result.items) == 0:
223
+ msg.reply("Empty results").send()
224
+ return
225
+
226
+ body = result.description + "\n"
227
+ for item in result.items:
228
+ for f in result.fields:
229
+ if f.type == "jid-single":
230
+ j = JID(item[f.var])
231
+ value = f"xmpp:{percent_encode(j)}"
232
+ if result.jids_are_mucs:
233
+ value += "?join"
234
+ else:
235
+ value = item[f.var] # type:ignore
236
+ body += f"\n{f.label or f.var}: {value}"
237
+ msg.reply(body).send()
238
+
239
+ @staticmethod
240
+ async def __wrap_handler(msg, f: Union[Callable, functools.partial], *a, **k):
241
+ try:
242
+ if asyncio.iscoroutinefunction(f):
243
+ return await f(*a, **k)
244
+ elif hasattr(f, "func") and asyncio.iscoroutinefunction(f.func):
245
+ return await f(*a, **k)
246
+ else:
247
+ return f(*a, **k)
248
+ except Exception as e:
249
+ log.debug("Error in %s", f, exc_info=e)
250
+ reply = msg.reply()
251
+ reply["body"] = f"Error: {e}"
252
+ reply.send()
253
+
254
+ def _handle_help(self, msg: Message, *rest):
255
+ if len(rest) == 0:
256
+ reply = msg.reply()
257
+ reply["body"] = self._help(msg.get_from())
258
+ reply.send()
259
+ elif len(rest) == 1 and (command := self._commands.get(rest[0])):
260
+ reply = msg.reply()
261
+ reply["body"] = f"{command.CHAT_COMMAND}: {command.NAME}\n{command.HELP}"
262
+ reply.send()
263
+ else:
264
+ self._not_found(msg, str(rest))
265
+
266
+ def _help(self, mfrom: JID):
267
+ msg = "Available commands:"
268
+ for c in sorted(
269
+ self._commands.values(), key=lambda co: (co.CATEGORY or "", co.CHAT_COMMAND)
270
+ ):
271
+ try:
272
+ c.raise_if_not_authorized(mfrom)
273
+ except XMPPError:
274
+ continue
275
+ msg += f"\n{c.CHAT_COMMAND} -- {c.NAME}"
276
+ return msg
277
+
278
+ def _not_found(self, msg: Message, word: str):
279
+ e = self.UNKNOWN.format(word)
280
+ msg.reply(e).send()
281
+ raise XMPPError("item-not-found", e)
282
+
283
+
284
+ def percent_encode(jid: JID):
285
+ return f"{url_quote(jid.user)}@{jid.server}" # type:ignore
286
+
287
+
288
+ log = logging.getLogger(__name__)
@@ -0,0 +1,179 @@
1
+ """
2
+ This module handles the registration :term:`Command`, which is a necessary
3
+ step for a JID to become a slidge :term:`User`.
4
+ """
5
+
6
+ import asyncio
7
+ import functools
8
+ import tempfile
9
+ from datetime import datetime
10
+ from enum import IntEnum
11
+ from typing import Any
12
+
13
+ import qrcode
14
+ from slixmpp import JID, Iq
15
+ from slixmpp.exceptions import XMPPError
16
+
17
+ from ..core import config
18
+ from ..util.db import GatewayUser
19
+ from .base import Command, CommandAccess, Form, FormField, FormValues
20
+
21
+
22
+ class RegistrationType(IntEnum):
23
+ """
24
+ An :class:`Enum` to define the registration flow.
25
+ """
26
+
27
+ SINGLE_STEP_FORM = 0
28
+ """
29
+ 1 step, 1 form, the only flow compatible with :xep:`0077`.
30
+ Using this, the whole flow is defined
31
+ by :attr:`slidge.BaseGateway.REGISTRATION_FIELDS` and
32
+ :attr:`.REGISTRATION_INSTRUCTIONS`.
33
+ """
34
+
35
+ QRCODE = 10
36
+ """
37
+ The registration requires flashing a QR code in an official client.
38
+ See :meth:`slidge.BaseGateway.send_qr`, :meth:`.get_qr_text`
39
+ and :meth:`.confirm_qr`.
40
+ """
41
+
42
+ TWO_FACTOR_CODE = 20
43
+ """
44
+ The registration requires confirming login with a 2FA code,
45
+ eg something received by email or SMS to finalize the authentication.
46
+ See :meth:`.validate_two_factor_code`.
47
+ """
48
+
49
+
50
+ class TwoFactorNotRequired(Exception):
51
+ """
52
+ Should be raised in :meth:`slidge.BaseGateway.validate` if the code is not
53
+ required after all. This can happen for a :term:`Legacy Network` where 2FA
54
+ is optional.
55
+ """
56
+
57
+ pass
58
+
59
+
60
+ class Register(Command):
61
+ NAME = "📝 Register to the gateway"
62
+ HELP = "Link your JID to this gateway"
63
+ NODE = "jabber:iq:register"
64
+ CHAT_COMMAND = "register"
65
+ ACCESS = CommandAccess.NON_USER
66
+
67
+ SUCCESS_MESSAGE = "Success, welcome!"
68
+
69
+ def _finalize(self, user: GatewayUser):
70
+ user.commit()
71
+ self.xmpp.event("user_register", Iq(sfrom=user.jid))
72
+ return self.SUCCESS_MESSAGE
73
+
74
+ async def run(self, _session, _ifrom, *_):
75
+ return Form(
76
+ title=f"Registration to '{self.xmpp.COMPONENT_NAME}'",
77
+ instructions=self.xmpp.REGISTRATION_INSTRUCTIONS,
78
+ fields=self.xmpp.REGISTRATION_FIELDS,
79
+ handler=self.register,
80
+ )
81
+
82
+ async def register(self, form_values: dict[str, Any], _session, ifrom: JID):
83
+ two_fa_needed = True
84
+ try:
85
+ await self.xmpp.user_prevalidate(ifrom, form_values)
86
+ except ValueError as e:
87
+ raise XMPPError("bad-request", str(e))
88
+ except TwoFactorNotRequired:
89
+ if self.xmpp.REGISTRATION_TYPE == RegistrationType.TWO_FACTOR_CODE:
90
+ two_fa_needed = False
91
+ else:
92
+ raise
93
+
94
+ user = GatewayUser(
95
+ bare_jid=ifrom.bare,
96
+ registration_form=form_values,
97
+ registration_date=datetime.now(),
98
+ )
99
+
100
+ if self.xmpp.REGISTRATION_TYPE == RegistrationType.SINGLE_STEP_FORM or (
101
+ self.xmpp.REGISTRATION_TYPE == RegistrationType.TWO_FACTOR_CODE
102
+ and not two_fa_needed
103
+ ):
104
+ return self._finalize(user)
105
+
106
+ if self.xmpp.REGISTRATION_TYPE == RegistrationType.TWO_FACTOR_CODE:
107
+ return Form(
108
+ title=self.xmpp.REGISTRATION_2FA_TITLE,
109
+ instructions=self.xmpp.REGISTRATION_2FA_INSTRUCTIONS,
110
+ fields=[FormField("code", label="Code", required=True)],
111
+ handler=functools.partial(self.two_fa, user=user),
112
+ )
113
+
114
+ elif self.xmpp.REGISTRATION_TYPE == RegistrationType.QRCODE:
115
+ self.xmpp.qr_pending_registrations[ # type:ignore
116
+ user.bare_jid
117
+ ] = (
118
+ self.xmpp.loop.create_future()
119
+ )
120
+ qr_text = await self.xmpp.get_qr_text(user)
121
+ qr = qrcode.make(qr_text)
122
+ with tempfile.NamedTemporaryFile(
123
+ suffix=".png", delete=config.NO_UPLOAD_METHOD != "move"
124
+ ) as f:
125
+ qr.save(f.name)
126
+ img_url, _ = await self.xmpp.send_file(f.name, mto=ifrom)
127
+ if img_url is None:
128
+ raise XMPPError(
129
+ "internal-server-error", "Slidge cannot send attachments"
130
+ )
131
+ self.xmpp.send_text(qr_text, mto=ifrom)
132
+ return Form(
133
+ title="Flash this",
134
+ instructions="Flash this QR in the appropriate place",
135
+ fields=[
136
+ FormField(
137
+ "qr_img",
138
+ type="fixed",
139
+ value=qr_text,
140
+ image_url=img_url,
141
+ ),
142
+ FormField(
143
+ "qr_text",
144
+ type="fixed",
145
+ value=qr_text,
146
+ label="Text encoded in the QR code",
147
+ ),
148
+ FormField(
149
+ "qr_img_url",
150
+ type="fixed",
151
+ value=img_url,
152
+ label="URL of the QR code image",
153
+ ),
154
+ ],
155
+ handler=functools.partial(self.qr, user=user),
156
+ )
157
+
158
+ async def two_fa(
159
+ self, form_values: FormValues, _session, _ifrom, user: GatewayUser
160
+ ):
161
+ assert isinstance(form_values["code"], str)
162
+ await self.xmpp.validate_two_factor_code(user, form_values["code"])
163
+ return self._finalize(user)
164
+
165
+ async def qr(self, _form_values: FormValues, _session, _ifrom, user: GatewayUser):
166
+ try:
167
+ await asyncio.wait_for(
168
+ self.xmpp.qr_pending_registrations[user.bare_jid], # type:ignore
169
+ config.QR_TIMEOUT,
170
+ )
171
+ except asyncio.TimeoutError:
172
+ raise XMPPError(
173
+ "remote-server-timeout",
174
+ (
175
+ "It does not seem that the QR code was correctly used, "
176
+ "or you took too much time"
177
+ ),
178
+ )
179
+ return self._finalize(user)