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.
- slidge/__init__.py +61 -0
- slidge/__main__.py +192 -0
- slidge/command/__init__.py +28 -0
- slidge/command/adhoc.py +258 -0
- slidge/command/admin.py +193 -0
- slidge/command/base.py +441 -0
- slidge/command/categories.py +3 -0
- slidge/command/chat_command.py +288 -0
- slidge/command/register.py +179 -0
- slidge/command/user.py +250 -0
- slidge/contact/__init__.py +8 -0
- slidge/contact/contact.py +452 -0
- slidge/contact/roster.py +192 -0
- slidge/core/__init__.py +3 -0
- slidge/core/cache.py +183 -0
- slidge/core/config.py +209 -0
- slidge/core/gateway/__init__.py +3 -0
- slidge/core/gateway/base.py +892 -0
- slidge/core/gateway/caps.py +63 -0
- slidge/core/gateway/delivery_receipt.py +52 -0
- slidge/core/gateway/disco.py +80 -0
- slidge/core/gateway/mam.py +75 -0
- slidge/core/gateway/muc_admin.py +35 -0
- slidge/core/gateway/ping.py +66 -0
- slidge/core/gateway/presence.py +95 -0
- slidge/core/gateway/registration.py +53 -0
- slidge/core/gateway/search.py +102 -0
- slidge/core/gateway/session_dispatcher.py +757 -0
- slidge/core/gateway/vcard_temp.py +130 -0
- slidge/core/mixins/__init__.py +19 -0
- slidge/core/mixins/attachment.py +506 -0
- slidge/core/mixins/avatar.py +167 -0
- slidge/core/mixins/base.py +31 -0
- slidge/core/mixins/disco.py +130 -0
- slidge/core/mixins/lock.py +31 -0
- slidge/core/mixins/message.py +398 -0
- slidge/core/mixins/message_maker.py +154 -0
- slidge/core/mixins/presence.py +217 -0
- slidge/core/mixins/recipient.py +43 -0
- slidge/core/pubsub.py +525 -0
- slidge/core/session.py +752 -0
- slidge/group/__init__.py +10 -0
- slidge/group/archive.py +125 -0
- slidge/group/bookmarks.py +163 -0
- slidge/group/participant.py +440 -0
- slidge/group/room.py +1095 -0
- slidge/migration.py +18 -0
- slidge/py.typed +0 -0
- slidge/slixfix/__init__.py +68 -0
- slidge/slixfix/link_preview/__init__.py +10 -0
- slidge/slixfix/link_preview/link_preview.py +17 -0
- slidge/slixfix/link_preview/stanza.py +99 -0
- slidge/slixfix/roster.py +60 -0
- slidge/slixfix/xep_0077/__init__.py +10 -0
- slidge/slixfix/xep_0077/register.py +289 -0
- slidge/slixfix/xep_0077/stanza.py +104 -0
- slidge/slixfix/xep_0100/__init__.py +5 -0
- slidge/slixfix/xep_0100/gateway.py +121 -0
- slidge/slixfix/xep_0100/stanza.py +9 -0
- slidge/slixfix/xep_0153/__init__.py +10 -0
- slidge/slixfix/xep_0153/stanza.py +25 -0
- slidge/slixfix/xep_0153/vcard_avatar.py +23 -0
- slidge/slixfix/xep_0264/__init__.py +5 -0
- slidge/slixfix/xep_0264/stanza.py +36 -0
- slidge/slixfix/xep_0264/thumbnail.py +23 -0
- slidge/slixfix/xep_0292/__init__.py +5 -0
- slidge/slixfix/xep_0292/vcard4.py +100 -0
- slidge/slixfix/xep_0313/__init__.py +12 -0
- slidge/slixfix/xep_0313/mam.py +262 -0
- slidge/slixfix/xep_0313/stanza.py +359 -0
- slidge/slixfix/xep_0317/__init__.py +5 -0
- slidge/slixfix/xep_0317/hats.py +17 -0
- slidge/slixfix/xep_0317/stanza.py +28 -0
- slidge/slixfix/xep_0356_old/__init__.py +7 -0
- slidge/slixfix/xep_0356_old/privilege.py +167 -0
- slidge/slixfix/xep_0356_old/stanza.py +44 -0
- slidge/slixfix/xep_0424/__init__.py +9 -0
- slidge/slixfix/xep_0424/retraction.py +77 -0
- slidge/slixfix/xep_0424/stanza.py +28 -0
- slidge/slixfix/xep_0490/__init__.py +8 -0
- slidge/slixfix/xep_0490/mds.py +47 -0
- slidge/slixfix/xep_0490/stanza.py +17 -0
- slidge/util/__init__.py +15 -0
- slidge/util/archive_msg.py +61 -0
- slidge/util/conf.py +206 -0
- slidge/util/db.py +229 -0
- slidge/util/schema.sql +126 -0
- slidge/util/sql.py +508 -0
- slidge/util/test.py +295 -0
- slidge/util/types.py +180 -0
- slidge/util/util.py +295 -0
- slidge-0.1.0.dist-info/LICENSE +661 -0
- slidge-0.1.0.dist-info/METADATA +109 -0
- slidge-0.1.0.dist-info/RECORD +96 -0
- slidge-0.1.0.dist-info/WHEEL +4 -0
- slidge-0.1.0.dist-info/entry_points.txt +3 -0
slidge/command/admin.py
ADDED
@@ -0,0 +1,193 @@
|
|
1
|
+
# Commands only accessible for slidge admins
|
2
|
+
import functools
|
3
|
+
import logging
|
4
|
+
from datetime import datetime
|
5
|
+
from typing import Any, Optional
|
6
|
+
|
7
|
+
from slixmpp import JID
|
8
|
+
from slixmpp.exceptions import XMPPError
|
9
|
+
|
10
|
+
from ..util.db import user_store
|
11
|
+
from ..util.types import AnyBaseSession
|
12
|
+
from .base import (
|
13
|
+
Command,
|
14
|
+
CommandAccess,
|
15
|
+
Confirmation,
|
16
|
+
Form,
|
17
|
+
FormField,
|
18
|
+
FormValues,
|
19
|
+
TableResult,
|
20
|
+
)
|
21
|
+
from .categories import ADMINISTRATION
|
22
|
+
|
23
|
+
|
24
|
+
class AdminCommand(Command):
|
25
|
+
ACCESS = CommandAccess.ADMIN_ONLY
|
26
|
+
CATEGORY = ADMINISTRATION
|
27
|
+
|
28
|
+
|
29
|
+
class ListUsers(AdminCommand):
|
30
|
+
NAME = "👤 List registered users"
|
31
|
+
HELP = "List the users registered to this gateway"
|
32
|
+
NODE = CHAT_COMMAND = "list_users"
|
33
|
+
|
34
|
+
async def run(self, _session, _ifrom, *_):
|
35
|
+
items = []
|
36
|
+
for u in user_store.get_all():
|
37
|
+
d = u.registration_date
|
38
|
+
if d is None:
|
39
|
+
joined = ""
|
40
|
+
else:
|
41
|
+
joined = d.isoformat(timespec="seconds")
|
42
|
+
items.append({"jid": u.bare_jid, "joined": joined})
|
43
|
+
return TableResult(
|
44
|
+
description="List of registered users",
|
45
|
+
fields=[FormField("jid", type="jid-single"), FormField("joined")],
|
46
|
+
items=items, # type:ignore
|
47
|
+
)
|
48
|
+
|
49
|
+
|
50
|
+
class SlidgeInfo(AdminCommand):
|
51
|
+
NAME = "ℹ️ Server information"
|
52
|
+
HELP = "List the users registered to this gateway"
|
53
|
+
NODE = CHAT_COMMAND = "info"
|
54
|
+
ACCESS = CommandAccess.ANY
|
55
|
+
|
56
|
+
async def run(self, _session, _ifrom, *_):
|
57
|
+
from ..__main__ import __version__
|
58
|
+
|
59
|
+
start = self.xmpp.datetime_started
|
60
|
+
uptime = datetime.now() - start
|
61
|
+
|
62
|
+
if uptime.days:
|
63
|
+
days_ago = f"{uptime.days} day{'s' if uptime.days != 1 else ''}"
|
64
|
+
else:
|
65
|
+
days_ago = None
|
66
|
+
hours, seconds = divmod(uptime.seconds, 3600)
|
67
|
+
|
68
|
+
if hours:
|
69
|
+
hours_ago = f"{hours} hour"
|
70
|
+
if hours != 1:
|
71
|
+
hours_ago += "s"
|
72
|
+
else:
|
73
|
+
hours_ago = None
|
74
|
+
|
75
|
+
minutes, seconds = divmod(seconds, 60)
|
76
|
+
if minutes:
|
77
|
+
minutes_ago = f"{minutes} minute"
|
78
|
+
if minutes_ago != 1:
|
79
|
+
minutes_ago += "s"
|
80
|
+
else:
|
81
|
+
minutes_ago = None
|
82
|
+
|
83
|
+
if any((days_ago, hours_ago, minutes_ago)):
|
84
|
+
seconds_ago = None
|
85
|
+
else:
|
86
|
+
seconds_ago = f"{seconds} second"
|
87
|
+
if seconds != 1:
|
88
|
+
seconds_ago += "s"
|
89
|
+
|
90
|
+
ago = ", ".join(
|
91
|
+
[a for a in (days_ago, hours_ago, minutes_ago, seconds_ago) if a]
|
92
|
+
)
|
93
|
+
|
94
|
+
return (
|
95
|
+
f"{self.xmpp.COMPONENT_NAME} version {__version__}\n"
|
96
|
+
f"Up since {start:%Y-%m-%d %H:%M} ({ago} ago)"
|
97
|
+
)
|
98
|
+
|
99
|
+
|
100
|
+
class DeleteUser(AdminCommand):
|
101
|
+
NAME = "❌ Delete a user"
|
102
|
+
HELP = "Unregister a user from the gateway"
|
103
|
+
NODE = CHAT_COMMAND = "delete_user"
|
104
|
+
|
105
|
+
async def run(self, _session, _ifrom, *_):
|
106
|
+
return Form(
|
107
|
+
title="Remove a slidge user",
|
108
|
+
instructions="Enter the bare JID of the user you want to delete",
|
109
|
+
fields=[FormField("jid", type="jid-single", label="JID", required=True)],
|
110
|
+
handler=self.delete,
|
111
|
+
)
|
112
|
+
|
113
|
+
async def delete(
|
114
|
+
self, form_values: FormValues, _session: AnyBaseSession, _ifrom: JID
|
115
|
+
) -> Confirmation:
|
116
|
+
jid: JID = form_values.get("jid") # type:ignore
|
117
|
+
user = user_store.get_by_jid(jid)
|
118
|
+
if user is None:
|
119
|
+
raise XMPPError("item-not-found", text=f"There is no user '{jid}'")
|
120
|
+
|
121
|
+
return Confirmation(
|
122
|
+
prompt=f"Are you sure you want to unregister '{jid}' from slidge?",
|
123
|
+
success=f"User {jid} has been deleted",
|
124
|
+
handler=functools.partial(self.finish, jid=jid),
|
125
|
+
)
|
126
|
+
|
127
|
+
async def finish(
|
128
|
+
self, _session: Optional[AnyBaseSession], _ifrom: JID, jid: JID
|
129
|
+
) -> None:
|
130
|
+
user = user_store.get_by_jid(jid)
|
131
|
+
if user is None:
|
132
|
+
raise XMPPError("bad-request", f"{jid} has no account here!")
|
133
|
+
await self.xmpp.unregister_user(user)
|
134
|
+
|
135
|
+
|
136
|
+
class ChangeLoglevel(AdminCommand):
|
137
|
+
NAME = "📋 Change the verbosity of the logs"
|
138
|
+
HELP = "Set the logging level"
|
139
|
+
NODE = CHAT_COMMAND = "loglevel"
|
140
|
+
|
141
|
+
async def run(self, _session, _ifrom, *_):
|
142
|
+
return Form(
|
143
|
+
title=self.NAME,
|
144
|
+
instructions=self.HELP,
|
145
|
+
fields=[
|
146
|
+
FormField(
|
147
|
+
"level",
|
148
|
+
label="Log level",
|
149
|
+
required=True,
|
150
|
+
type="list-single",
|
151
|
+
options=[
|
152
|
+
{"label": "WARNING (quiet)", "value": str(logging.WARNING)},
|
153
|
+
{"label": "INFO (normal)", "value": str(logging.INFO)},
|
154
|
+
{"label": "DEBUG (verbose)", "value": str(logging.DEBUG)},
|
155
|
+
],
|
156
|
+
)
|
157
|
+
],
|
158
|
+
handler=self.finish,
|
159
|
+
)
|
160
|
+
|
161
|
+
@staticmethod
|
162
|
+
async def finish(
|
163
|
+
form_values: FormValues, _session: AnyBaseSession, _ifrom: JID
|
164
|
+
) -> None:
|
165
|
+
logging.getLogger().setLevel(int(form_values["level"])) # type:ignore
|
166
|
+
|
167
|
+
|
168
|
+
class Exec(AdminCommand):
|
169
|
+
NAME = HELP = "Exec arbitrary python code. SHOULD NEVER BE AVAILABLE IN PROD."
|
170
|
+
CHAT_COMMAND = "!"
|
171
|
+
NODE = "exec"
|
172
|
+
ACCESS = CommandAccess.ADMIN_ONLY
|
173
|
+
|
174
|
+
prev_snapshot = None
|
175
|
+
|
176
|
+
context = dict[str, Any]()
|
177
|
+
|
178
|
+
def __init__(self, xmpp):
|
179
|
+
super().__init__(xmpp)
|
180
|
+
|
181
|
+
async def run(self, session, ifrom: JID, *args):
|
182
|
+
from contextlib import redirect_stdout
|
183
|
+
from io import StringIO
|
184
|
+
|
185
|
+
f = StringIO()
|
186
|
+
with redirect_stdout(f):
|
187
|
+
exec(" ".join(args), self.context)
|
188
|
+
|
189
|
+
out = f.getvalue()
|
190
|
+
if out:
|
191
|
+
return f"```\n{out}\n```"
|
192
|
+
else:
|
193
|
+
return "No output"
|
slidge/command/base.py
ADDED
@@ -0,0 +1,441 @@
|
|
1
|
+
from abc import ABC
|
2
|
+
from dataclasses import dataclass, field
|
3
|
+
from enum import Enum
|
4
|
+
from typing import (
|
5
|
+
TYPE_CHECKING,
|
6
|
+
Any,
|
7
|
+
Awaitable,
|
8
|
+
Callable,
|
9
|
+
Collection,
|
10
|
+
Iterable,
|
11
|
+
Optional,
|
12
|
+
Type,
|
13
|
+
TypedDict,
|
14
|
+
Union,
|
15
|
+
)
|
16
|
+
|
17
|
+
from slixmpp import JID # type: ignore[attr-defined]
|
18
|
+
from slixmpp.exceptions import XMPPError
|
19
|
+
from slixmpp.plugins.xep_0004 import Form as SlixForm # type: ignore[attr-defined]
|
20
|
+
from slixmpp.plugins.xep_0004 import (
|
21
|
+
FormField as SlixFormField, # type: ignore[attr-defined]
|
22
|
+
)
|
23
|
+
from slixmpp.types import JidStr
|
24
|
+
|
25
|
+
from ..core import config
|
26
|
+
from ..util.db import user_store
|
27
|
+
from ..util.types import AnyBaseSession, FieldType
|
28
|
+
|
29
|
+
if TYPE_CHECKING:
|
30
|
+
from ..core.gateway import BaseGateway
|
31
|
+
from ..core.session import BaseSession
|
32
|
+
|
33
|
+
|
34
|
+
HandlerType = Union[
|
35
|
+
Callable[[AnyBaseSession, JID], "CommandResponseType"],
|
36
|
+
Callable[[AnyBaseSession, JID], Awaitable["CommandResponseType"]],
|
37
|
+
]
|
38
|
+
|
39
|
+
FormValues = dict[str, Union[str, JID, bool]]
|
40
|
+
|
41
|
+
|
42
|
+
FormHandlerType = Callable[
|
43
|
+
[FormValues, AnyBaseSession, JID],
|
44
|
+
Awaitable["CommandResponseType"],
|
45
|
+
]
|
46
|
+
|
47
|
+
ConfirmationHandlerType = Callable[
|
48
|
+
[Optional[AnyBaseSession], JID], Awaitable["CommandResponseType"]
|
49
|
+
]
|
50
|
+
|
51
|
+
|
52
|
+
@dataclass
|
53
|
+
class TableResult:
|
54
|
+
"""
|
55
|
+
Structured data as the result of a command
|
56
|
+
"""
|
57
|
+
|
58
|
+
fields: Collection["FormField"]
|
59
|
+
"""
|
60
|
+
The 'columns names' of the table.
|
61
|
+
"""
|
62
|
+
items: Collection[dict[str, Union[str, JID]]]
|
63
|
+
"""
|
64
|
+
The rows of the table. Each row is a dict where keys are the fields ``var``
|
65
|
+
attribute.
|
66
|
+
"""
|
67
|
+
description: str
|
68
|
+
"""
|
69
|
+
A description of the content of the table.
|
70
|
+
"""
|
71
|
+
|
72
|
+
jids_are_mucs: bool = False
|
73
|
+
|
74
|
+
def get_xml(self) -> SlixForm:
|
75
|
+
"""
|
76
|
+
Get a slixmpp "form" (with <reported> header)to represent the data
|
77
|
+
|
78
|
+
:return: some XML
|
79
|
+
"""
|
80
|
+
form = SlixForm() # type: ignore[no-untyped-call]
|
81
|
+
form["type"] = "result"
|
82
|
+
form["title"] = self.description
|
83
|
+
for f in self.fields:
|
84
|
+
form.add_reported(f.var, label=f.label, type=f.type) # type: ignore[no-untyped-call]
|
85
|
+
for item in self.items:
|
86
|
+
form.add_item({k: str(v) for k, v in item.items()}) # type: ignore[no-untyped-call]
|
87
|
+
return form
|
88
|
+
|
89
|
+
|
90
|
+
@dataclass
|
91
|
+
class SearchResult(TableResult):
|
92
|
+
"""
|
93
|
+
Results of the search command (search for contacts via Jabber Search)
|
94
|
+
|
95
|
+
Return type of :meth:`BaseSession.search`.
|
96
|
+
"""
|
97
|
+
|
98
|
+
description: str = "Contact search results"
|
99
|
+
|
100
|
+
|
101
|
+
@dataclass
|
102
|
+
class Confirmation:
|
103
|
+
"""
|
104
|
+
A confirmation 'dialog'
|
105
|
+
"""
|
106
|
+
|
107
|
+
prompt: str
|
108
|
+
"""
|
109
|
+
The text presented to the command triggering user
|
110
|
+
"""
|
111
|
+
handler: ConfirmationHandlerType
|
112
|
+
"""
|
113
|
+
An async function that should return a ResponseType
|
114
|
+
"""
|
115
|
+
success: Optional[str] = None
|
116
|
+
"""
|
117
|
+
Text in case of success, used if handler does not return anything
|
118
|
+
"""
|
119
|
+
handler_args: Iterable[Any] = field(default_factory=list)
|
120
|
+
"""
|
121
|
+
arguments passed to the handler
|
122
|
+
"""
|
123
|
+
handler_kwargs: dict[str, Any] = field(default_factory=dict)
|
124
|
+
"""
|
125
|
+
keyword arguments passed to the handler
|
126
|
+
"""
|
127
|
+
|
128
|
+
def get_form(self) -> SlixForm:
|
129
|
+
"""
|
130
|
+
Get the slixmpp form
|
131
|
+
|
132
|
+
:return: some xml
|
133
|
+
"""
|
134
|
+
form = SlixForm() # type: ignore[no-untyped-call]
|
135
|
+
form["type"] = "form"
|
136
|
+
form["title"] = self.prompt
|
137
|
+
form.append(
|
138
|
+
FormField(
|
139
|
+
"confirm", type="boolean", value="true", label="Confirm"
|
140
|
+
).get_xml()
|
141
|
+
)
|
142
|
+
return form
|
143
|
+
|
144
|
+
|
145
|
+
@dataclass
|
146
|
+
class Form:
|
147
|
+
"""
|
148
|
+
A form, to request user input
|
149
|
+
"""
|
150
|
+
|
151
|
+
title: str
|
152
|
+
instructions: str
|
153
|
+
fields: Collection["FormField"]
|
154
|
+
handler: FormHandlerType
|
155
|
+
handler_args: Iterable[Any] = field(default_factory=list)
|
156
|
+
handler_kwargs: dict[str, Any] = field(default_factory=dict)
|
157
|
+
|
158
|
+
def get_values(
|
159
|
+
self, slix_form: SlixForm
|
160
|
+
) -> dict[str, Union[list[str], list[JID], str, JID, bool, None]]:
|
161
|
+
"""
|
162
|
+
Parse form submission
|
163
|
+
|
164
|
+
:param slix_form: the xml received as the submission of a form
|
165
|
+
:return: A dict where keys=field.var and values are either strings
|
166
|
+
or JIDs (if field.type=jid-single)
|
167
|
+
"""
|
168
|
+
str_values: dict[str, str] = slix_form.get_values() # type: ignore[no-untyped-call]
|
169
|
+
values = {}
|
170
|
+
for f in self.fields:
|
171
|
+
values[f.var] = f.validate(str_values.get(f.var))
|
172
|
+
return values
|
173
|
+
|
174
|
+
def get_xml(self) -> SlixForm:
|
175
|
+
"""
|
176
|
+
Get the slixmpp "form"
|
177
|
+
|
178
|
+
:return: some XML
|
179
|
+
"""
|
180
|
+
form = SlixForm() # type: ignore[no-untyped-call]
|
181
|
+
form["type"] = "form"
|
182
|
+
form["instructions"] = self.instructions
|
183
|
+
form["title"] = self.title
|
184
|
+
for fi in self.fields:
|
185
|
+
form.append(fi.get_xml())
|
186
|
+
return form
|
187
|
+
|
188
|
+
|
189
|
+
class CommandAccess(int, Enum):
|
190
|
+
"""
|
191
|
+
Defines who can access a given Command
|
192
|
+
"""
|
193
|
+
|
194
|
+
ADMIN_ONLY = 0
|
195
|
+
USER = 1
|
196
|
+
USER_LOGGED = 2
|
197
|
+
USER_NON_LOGGED = 3
|
198
|
+
NON_USER = 4
|
199
|
+
ANY = 5
|
200
|
+
|
201
|
+
|
202
|
+
class Option(TypedDict):
|
203
|
+
"""
|
204
|
+
Options to be used for ``FormField``s of type ``list-*``
|
205
|
+
"""
|
206
|
+
|
207
|
+
label: str
|
208
|
+
value: str
|
209
|
+
|
210
|
+
|
211
|
+
# TODO: support forms validation XEP-0122
|
212
|
+
@dataclass
|
213
|
+
class FormField:
|
214
|
+
"""
|
215
|
+
Represents a field of the form that a user will see when registering to the gateway
|
216
|
+
via their XMPP client.
|
217
|
+
"""
|
218
|
+
|
219
|
+
var: str = ""
|
220
|
+
"""
|
221
|
+
Internal name of the field, will be used to retrieve via :py:attr:`slidge.GatewayUser.registration_form`
|
222
|
+
"""
|
223
|
+
label: Optional[str] = None
|
224
|
+
"""Description of the field that the user will see"""
|
225
|
+
required: bool = False
|
226
|
+
"""Whether this field is mandatory or not"""
|
227
|
+
private: bool = False
|
228
|
+
"""
|
229
|
+
For sensitive info that should not be displayed on screen while the user types.
|
230
|
+
Forces field_type to "text-private"
|
231
|
+
"""
|
232
|
+
type: FieldType = "text-single"
|
233
|
+
"""Type of the field, see `XEP-0004 <https://xmpp.org/extensions/xep-0004.html#protocol-fieldtypes>`_"""
|
234
|
+
value: str = ""
|
235
|
+
"""Pre-filled value. Will be automatically pre-filled if a registered user modifies their subscription"""
|
236
|
+
options: Optional[list[Option]] = None
|
237
|
+
|
238
|
+
image_url: Optional[str] = None
|
239
|
+
"""An image associated to this field, eg, a QR code"""
|
240
|
+
|
241
|
+
def __post_init__(self) -> None:
|
242
|
+
if self.private:
|
243
|
+
self.type = "text-private"
|
244
|
+
|
245
|
+
def __acceptable_options(self) -> list[str]:
|
246
|
+
if not self.options:
|
247
|
+
raise RuntimeError
|
248
|
+
return [x["value"] for x in self.options]
|
249
|
+
|
250
|
+
def validate(
|
251
|
+
self, value: Optional[Union[str, list[str]]]
|
252
|
+
) -> Union[list[str], list[JID], str, JID, bool, None]:
|
253
|
+
"""
|
254
|
+
Raise appropriate XMPPError if a given value is valid for this field
|
255
|
+
|
256
|
+
:param value: The value to test
|
257
|
+
:return: The same value OR a JID if ``self.type=jid-single``
|
258
|
+
"""
|
259
|
+
if isinstance(value, list) and not self.type.endswith("multi"):
|
260
|
+
raise XMPPError("not-acceptable", "A single value was expected")
|
261
|
+
|
262
|
+
if self.type in ("list-multi", "jid-multi"):
|
263
|
+
if not value:
|
264
|
+
value = []
|
265
|
+
if isinstance(value, list):
|
266
|
+
return self.__validate_list_multi(value)
|
267
|
+
else:
|
268
|
+
raise XMPPError("not-acceptable", "Multiple values was expected")
|
269
|
+
|
270
|
+
assert isinstance(value, (str, bool, JID)) or value is None
|
271
|
+
|
272
|
+
if self.required and value is None:
|
273
|
+
raise XMPPError("not-acceptable", f"Missing field: '{self.label}'")
|
274
|
+
|
275
|
+
if value is None:
|
276
|
+
return None
|
277
|
+
|
278
|
+
if self.type == "jid-single":
|
279
|
+
try:
|
280
|
+
return JID(value)
|
281
|
+
except ValueError:
|
282
|
+
raise XMPPError("not-acceptable", f"Not a valid JID: '{value}'")
|
283
|
+
|
284
|
+
elif self.type == "list-single":
|
285
|
+
if value not in self.__acceptable_options():
|
286
|
+
raise XMPPError("not-acceptable", f"Not a valid option: '{value}'")
|
287
|
+
|
288
|
+
elif self.type == "boolean":
|
289
|
+
return value.lower() in ("1", "true") if isinstance(value, str) else value
|
290
|
+
|
291
|
+
return value
|
292
|
+
|
293
|
+
def __validate_list_multi(self, value: list[str]) -> Union[list[str], list[JID]]:
|
294
|
+
for v in value:
|
295
|
+
if v not in self.__acceptable_options():
|
296
|
+
raise XMPPError("not-acceptable", f"Not a valid option: '{v}'")
|
297
|
+
if self.type == "list-multi":
|
298
|
+
return value
|
299
|
+
return [JID(v) for v in value]
|
300
|
+
|
301
|
+
def get_xml(self) -> SlixFormField:
|
302
|
+
"""
|
303
|
+
Get the field in slixmpp format
|
304
|
+
|
305
|
+
:return: some XML
|
306
|
+
"""
|
307
|
+
f = SlixFormField()
|
308
|
+
f["var"] = self.var
|
309
|
+
f["label"] = self.label
|
310
|
+
f["required"] = self.required
|
311
|
+
f["type"] = self.type
|
312
|
+
if self.options:
|
313
|
+
for o in self.options:
|
314
|
+
f.add_option(**o) # type: ignore[no-untyped-call]
|
315
|
+
f["value"] = self.value
|
316
|
+
if self.image_url:
|
317
|
+
f["media"].add_uri(self.image_url, itype="image/png")
|
318
|
+
return f
|
319
|
+
|
320
|
+
|
321
|
+
CommandResponseType = Union[TableResult, Confirmation, Form, str, None]
|
322
|
+
|
323
|
+
|
324
|
+
class Command(ABC):
|
325
|
+
"""
|
326
|
+
Abstract base class to implement gateway commands (chatbot and ad-hoc)
|
327
|
+
"""
|
328
|
+
|
329
|
+
NAME: str = NotImplemented
|
330
|
+
"""
|
331
|
+
Friendly name of the command, eg: "do something with stuff"
|
332
|
+
"""
|
333
|
+
HELP: str = NotImplemented
|
334
|
+
"""
|
335
|
+
Long description of what the command does
|
336
|
+
"""
|
337
|
+
NODE: str = NotImplemented
|
338
|
+
"""
|
339
|
+
Name of the node used for ad-hoc commands
|
340
|
+
"""
|
341
|
+
CHAT_COMMAND: str = NotImplemented
|
342
|
+
"""
|
343
|
+
Text to send to the gateway to trigger the command via a message
|
344
|
+
"""
|
345
|
+
|
346
|
+
ACCESS: "CommandAccess" = NotImplemented
|
347
|
+
"""
|
348
|
+
Who can use this command
|
349
|
+
"""
|
350
|
+
|
351
|
+
CATEGORY: Optional[str] = None
|
352
|
+
"""
|
353
|
+
If used, the command will be under this top-level category.
|
354
|
+
Use the same string for several commands to group them.
|
355
|
+
This hierarchy only used for the adhoc interface, not the chat command
|
356
|
+
interface.
|
357
|
+
"""
|
358
|
+
|
359
|
+
subclasses = list[Type["Command"]]()
|
360
|
+
|
361
|
+
def __init__(self, xmpp: "BaseGateway"):
|
362
|
+
self.xmpp = xmpp
|
363
|
+
|
364
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
365
|
+
# store subclasses so subclassing is enough for the command to be
|
366
|
+
# picked up by slidge
|
367
|
+
cls.subclasses.append(cls)
|
368
|
+
|
369
|
+
async def run(
|
370
|
+
self, session: Optional["BaseSession[Any, Any]"], ifrom: JID, *args: str
|
371
|
+
) -> CommandResponseType:
|
372
|
+
"""
|
373
|
+
Entry point of the command
|
374
|
+
|
375
|
+
:param session: If triggered by a registered user, its slidge Session
|
376
|
+
:param ifrom: JID of the command-triggering entity
|
377
|
+
:param args: When triggered via chatbot type message, additional words
|
378
|
+
after the CHAT_COMMAND string was passed
|
379
|
+
|
380
|
+
:return: Either a TableResult, a Form, a Confirmation, a text, or None
|
381
|
+
"""
|
382
|
+
raise XMPPError("feature-not-implemented")
|
383
|
+
|
384
|
+
def _get_session(self, jid: JID) -> Optional["BaseSession[Any, Any]"]:
|
385
|
+
user = user_store.get_by_jid(jid)
|
386
|
+
if user is None:
|
387
|
+
return None
|
388
|
+
|
389
|
+
return self.xmpp.get_session_from_user(user)
|
390
|
+
|
391
|
+
def __can_use_command(self, jid: JID):
|
392
|
+
j = jid.bare
|
393
|
+
return self.xmpp.jid_validator.match(j) or j in config.ADMINS
|
394
|
+
|
395
|
+
def raise_if_not_authorized(self, jid: JID) -> Optional["BaseSession[Any, Any]"]:
|
396
|
+
"""
|
397
|
+
Raise an appropriate error is jid is not authorized to use the command
|
398
|
+
|
399
|
+
:param jid: jid of the entity trying to access the command
|
400
|
+
:return:session of JID if it exists
|
401
|
+
"""
|
402
|
+
session = self._get_session(jid)
|
403
|
+
if not self.__can_use_command(jid):
|
404
|
+
raise XMPPError(
|
405
|
+
"bad-request", "Your JID is not allowed to use this gateway."
|
406
|
+
)
|
407
|
+
|
408
|
+
if self.ACCESS == CommandAccess.ADMIN_ONLY and not is_admin(jid):
|
409
|
+
raise XMPPError("not-authorized")
|
410
|
+
elif self.ACCESS == CommandAccess.NON_USER and session is not None:
|
411
|
+
raise XMPPError(
|
412
|
+
"bad-request", "This is only available for non-users. Unregister first."
|
413
|
+
)
|
414
|
+
elif self.ACCESS == CommandAccess.USER and session is None:
|
415
|
+
raise XMPPError(
|
416
|
+
"forbidden",
|
417
|
+
"This is only available for users that are registered to this gateway",
|
418
|
+
)
|
419
|
+
elif self.ACCESS == CommandAccess.USER_NON_LOGGED:
|
420
|
+
if session is None or session.logged:
|
421
|
+
raise XMPPError(
|
422
|
+
"forbidden",
|
423
|
+
(
|
424
|
+
"This is only available for users that are not logged to the"
|
425
|
+
" legacy service"
|
426
|
+
),
|
427
|
+
)
|
428
|
+
elif self.ACCESS == CommandAccess.USER_LOGGED:
|
429
|
+
if session is None or not session.logged:
|
430
|
+
raise XMPPError(
|
431
|
+
"forbidden",
|
432
|
+
(
|
433
|
+
"This is only available when you are logged in to the legacy"
|
434
|
+
" service"
|
435
|
+
),
|
436
|
+
)
|
437
|
+
return session
|
438
|
+
|
439
|
+
|
440
|
+
def is_admin(jid: JidStr) -> bool:
|
441
|
+
return JID(jid).bare in config.ADMINS
|