Python-3xui 0.0.9__tar.gz → 0.0.9.post3__tar.gz
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.
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/PKG-INFO +7 -12
- python_3xui-0.0.9.post3/README.md +12 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/pyproject.toml +1 -2
- python_3xui-0.0.9.post3/python_3xui/__init__.py +10 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/python_3xui/api.py +224 -87
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/python_3xui/base_model.py +11 -8
- python_3xui-0.0.9.post3/python_3xui/custom_exceptions.py +27 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/python_3xui/endpoints.py +33 -28
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/python_3xui/models.py +31 -21
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/python_3xui/util.py +42 -12
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/conftest.py +1 -1
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_non_idempotent_endpoints_clients.py +1 -1
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_xuiclient_helpers.py +1 -1
- python_3xui-0.0.9/README.md +0 -16
- python_3xui-0.0.9/python_3xui/__init__.py +0 -7
- python_3xui-0.0.9/python_3xui/custom_exceptions.py +0 -12
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/.gitignore +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/LICENSE +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/gather_response_stubs.py +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/pytest.ini +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_endpoints_clients.py +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_endpoints_inbounds.py +0 -0
- {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_non_idempotent_endpoints_inbounds.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: Python-3xui
|
|
3
|
-
Version: 0.0.9
|
|
3
|
+
Version: 0.0.9.post3
|
|
4
4
|
Summary: 3x-ui wrapper for python
|
|
5
5
|
Project-URL: Homepage, https://github.com/Artem-Potapov/3x-py
|
|
6
6
|
Project-URL: Issues, https://github.com/Artem-Potapov/3x-py/issues
|
|
@@ -17,7 +17,6 @@ Requires-Dist: dotenv~=0.9.9
|
|
|
17
17
|
Requires-Dist: httpx~=0.28.1
|
|
18
18
|
Requires-Dist: pydantic<3,~=2.12.5
|
|
19
19
|
Requires-Dist: pyotp~=2.9.0
|
|
20
|
-
Requires-Dist: python-dotenv
|
|
21
20
|
Provides-Extra: testing
|
|
22
21
|
Requires-Dist: pytest; extra == 'testing'
|
|
23
22
|
Requires-Dist: pytest-asyncio; extra == 'testing'
|
|
@@ -29,15 +28,11 @@ Description-Content-Type: text/markdown
|
|
|
29
28
|
<p>I'm not expecting much to be honest, so please feel free to fork it if I abandon the project and you need it!</p>
|
|
30
29
|
<p>Also, if you REALLY want it I can give you the ownership if I step down, you can find my email in the pyproject.toml (I don't check it that much but trust me I do)</p>
|
|
31
30
|
|
|
32
|
-
<h2>0.0.9 Release Notes</h2>
|
|
31
|
+
<h2>0.0.9-r3 Release Notes</h2>
|
|
33
32
|
<ul>
|
|
34
|
-
<li>
|
|
35
|
-
<li>
|
|
36
|
-
<li>
|
|
37
|
-
<li>
|
|
38
|
-
<li>
|
|
39
|
-
<li>Remove obsolete and useless client fields from models</li>
|
|
40
|
-
<li>Inbound settings actually get parsed properly into ClientsSettings</li>
|
|
41
|
-
<li>New asyncio task management so they won't get destroyed when GCed</li>
|
|
42
|
-
<li>XUIClient async_lru cache now binds to event loop at runtime, not in initialization</li>
|
|
33
|
+
<li>HOTFIX: the importing of util.py fixed with from __future__ import annotations</li>
|
|
34
|
+
<li>Make panel_id for better accounting & logging clarity</li>
|
|
35
|
+
<li>Fix __aenter__ in XUIClient to not log a warning</li>
|
|
36
|
+
<li>Fix total_gb to be int and not float, since that would need refactoring which I don't have time for yet.</li>
|
|
37
|
+
<li>ClientsSettings now has extra=ignore instead of extra=forbid.</li>
|
|
43
38
|
</ul>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<h1>Hi! This is my example python 3x-ui wrapper!</h1>
|
|
2
|
+
<p>I'm not expecting much to be honest, so please feel free to fork it if I abandon the project and you need it!</p>
|
|
3
|
+
<p>Also, if you REALLY want it I can give you the ownership if I step down, you can find my email in the pyproject.toml (I don't check it that much but trust me I do)</p>
|
|
4
|
+
|
|
5
|
+
<h2>0.0.9-r3 Release Notes</h2>
|
|
6
|
+
<ul>
|
|
7
|
+
<li>HOTFIX: the importing of util.py fixed with from __future__ import annotations</li>
|
|
8
|
+
<li>Make panel_id for better accounting & logging clarity</li>
|
|
9
|
+
<li>Fix __aenter__ in XUIClient to not log a warning</li>
|
|
10
|
+
<li>Fix total_gb to be int and not float, since that would need refactoring which I don't have time for yet.</li>
|
|
11
|
+
<li>ClientsSettings now has extra=ignore instead of extra=forbid.</li>
|
|
12
|
+
</ul>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "Python-3xui"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.9r3"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name="JustMe_001", email="justme001.causation755@passinbox.com" },
|
|
6
6
|
]
|
|
@@ -23,7 +23,6 @@ dependencies = [
|
|
|
23
23
|
"pydantic ~= 2.12.5, < 3",
|
|
24
24
|
"httpx ~=0.28.1",
|
|
25
25
|
"dotenv ~= 0.9.9",
|
|
26
|
-
"python-dotenv",
|
|
27
26
|
"async_lru ~= 2.3.0",
|
|
28
27
|
"pyotp ~= 2.9.0"
|
|
29
28
|
]
|
|
@@ -8,6 +8,7 @@ from datetime import datetime, UTC
|
|
|
8
8
|
from inspect import iscoroutinefunction
|
|
9
9
|
from logging import DEBUG
|
|
10
10
|
from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal, Callable, Awaitable, overload
|
|
11
|
+
import contextlib
|
|
11
12
|
|
|
12
13
|
import httpx
|
|
13
14
|
import pyotp
|
|
@@ -17,19 +18,20 @@ from pydantic import SecretStr
|
|
|
17
18
|
|
|
18
19
|
from . import custom_exceptions
|
|
19
20
|
from . import util
|
|
21
|
+
from . import endpoints
|
|
20
22
|
from .models import Inbound, SingleInboundClient, ClientStats
|
|
21
|
-
from .util import JsonType, async_range
|
|
23
|
+
from .util import JsonType, async_range, get_inbound_in_client
|
|
22
24
|
|
|
23
25
|
DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
|
|
24
26
|
PrimitiveData = Optional[Union[str, int, float, bool]]
|
|
25
27
|
ParamType = Union[
|
|
26
28
|
Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]],
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
list[Tuple[str, PrimitiveData]],
|
|
30
|
+
tuple[Tuple[str, PrimitiveData], ...],
|
|
29
31
|
str,
|
|
30
32
|
bytes,
|
|
31
33
|
]
|
|
32
|
-
CookieType = Union[Dict[str, str],
|
|
34
|
+
CookieType = Union[Dict[str, str], list[tuple[str, str]]]
|
|
33
35
|
HeaderType = Union[
|
|
34
36
|
Mapping[str, str],
|
|
35
37
|
Mapping[bytes, bytes],
|
|
@@ -43,21 +45,26 @@ class XUIClient:
|
|
|
43
45
|
|
|
44
46
|
This class provides methods for authenticating with the 3X-UI panel,
|
|
45
47
|
managing sessions, and performing operations on inbounds and clients.
|
|
48
|
+
It also owns the endpoint handlers and the per-instance production
|
|
49
|
+
inbound cache.
|
|
46
50
|
|
|
47
51
|
Attributes:
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
connected: Whether an HTTP session is currently open.
|
|
53
|
+
PROD_STRING: Compiled regex used to identify production inbounds.
|
|
54
|
+
session: The async HTTP client session, if connected.
|
|
50
55
|
base_host: The server hostname.
|
|
51
56
|
base_port: The server port.
|
|
52
57
|
base_path: The base path for the API.
|
|
53
58
|
base_url: The full base URL for API requests.
|
|
54
59
|
session_start: Timestamp of when the session was created.
|
|
55
60
|
session_duration: Maximum session duration in seconds.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
xui_username: Username for authentication.
|
|
62
|
+
xui_password: Password for authentication.
|
|
63
|
+
two_fac_secret: TOTP secret or one-shot 2FA code, if configured.
|
|
64
|
+
totp: TOTP generator used for repeated logins when a secret is provided.
|
|
59
65
|
max_retries: Maximum number of retry attempts for failed requests.
|
|
60
66
|
retry_delay: Delay in seconds between retries.
|
|
67
|
+
sub_gen: Callable used to derive subscription IDs from Telegram IDs.
|
|
61
68
|
server_end: Server endpoint handler.
|
|
62
69
|
clients_end: Clients endpoint handler.
|
|
63
70
|
inbounds_end: Inbounds endpoint handler.
|
|
@@ -67,8 +74,9 @@ class XUIClient:
|
|
|
67
74
|
*, username: str | None = None, password: str | None = None,
|
|
68
75
|
two_fac_code: str | None = None, session_duration: int = 3600,
|
|
69
76
|
custom_prod_string: str = "testing",
|
|
70
|
-
max_retries: int = 5, retry_delay
|
|
71
|
-
custom_sub_generator: Callable[[int], str]|Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
|
|
77
|
+
max_retries: int = 5, retry_delay=1,
|
|
78
|
+
custom_sub_generator: Callable[[int], str] | Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
|
|
79
|
+
panel_id: Any = None
|
|
72
80
|
) -> None:
|
|
73
81
|
"""Initialize the XUIClient.
|
|
74
82
|
|
|
@@ -78,10 +86,16 @@ class XUIClient:
|
|
|
78
86
|
base_path: The base path for the API (e.g., "/panel").
|
|
79
87
|
username: Username for authentication.
|
|
80
88
|
password: Password for authentication.
|
|
81
|
-
two_fac_code:
|
|
89
|
+
two_fac_code: TOTP secret for 2FA. Short one-shot codes are
|
|
90
|
+
accepted for the current login only.
|
|
82
91
|
session_duration: Maximum session duration in seconds. Defaults to 3600.
|
|
92
|
+
custom_prod_string: Regex pattern used to select production inbounds.
|
|
93
|
+
max_retries: Maximum retries for database-lock responses.
|
|
94
|
+
retry_delay: Seconds to wait between database-lock retries.
|
|
95
|
+
custom_sub_generator: Sync or async callable that receives a
|
|
96
|
+
Telegram ID and returns the subscription ID for new clients.
|
|
97
|
+
panel_id: this is solely for user's purposes to increase logging and accounting clarity. Default is None.
|
|
83
98
|
"""
|
|
84
|
-
from . import endpoints # look, I know it's bad, but we need to evade cyclical imports
|
|
85
99
|
self.connected: bool = False
|
|
86
100
|
self.PROD_STRING = re.compile(custom_prod_string)
|
|
87
101
|
self.session: AsyncClient | None = None
|
|
@@ -98,6 +112,7 @@ class XUIClient:
|
|
|
98
112
|
self.max_retries: int = max_retries
|
|
99
113
|
self.retry_delay: int = retry_delay
|
|
100
114
|
self.sub_gen = custom_sub_generator
|
|
115
|
+
self.panel_id: int | str | Any = panel_id
|
|
101
116
|
# endpoints
|
|
102
117
|
self.server_end = endpoints.Server(self)
|
|
103
118
|
self.clients_end = endpoints.Clients(self)
|
|
@@ -107,7 +122,7 @@ class XUIClient:
|
|
|
107
122
|
# a new XUIClient on a fresh loop (e.g. each pytest-asyncio test). Building the wrapper here gives every
|
|
108
123
|
# instance its own cache bound to its own loop.
|
|
109
124
|
self.get_production_inbounds = alru_cache(maxsize=128)(self._get_production_inbounds_impl)
|
|
110
|
-
self._cache_cleaner_task: Task|None = None
|
|
125
|
+
self._cache_cleaner_task: Task | None = None
|
|
111
126
|
#init self.totp
|
|
112
127
|
if self.two_fac_secret:
|
|
113
128
|
if len(self.two_fac_secret.get_secret_value()) <= 8:
|
|
@@ -129,33 +144,52 @@ class XUIClient:
|
|
|
129
144
|
...
|
|
130
145
|
|
|
131
146
|
async def _safe_request(self,
|
|
132
|
-
method: Literal["get", "post", "patch", "delete", "put"]|None=None,
|
|
147
|
+
method: Literal["get", "post", "patch", "delete", "put"] | None = None,
|
|
133
148
|
**kwargs) -> Response:
|
|
134
149
|
"""Execute an HTTP request with automatic retry on database lock.
|
|
135
150
|
|
|
136
|
-
|
|
137
|
-
|
|
151
|
+
The request can be made either from a prebuilt ``request_to_send`` or
|
|
152
|
+
from an HTTP method plus keyword arguments accepted by ``httpx``.
|
|
153
|
+
The method handles automatic session refresh on expired 404 responses
|
|
154
|
+
and retries when the 3X-UI database is locked.
|
|
138
155
|
|
|
139
156
|
Args:
|
|
140
|
-
method: The HTTP method to use.
|
|
141
|
-
**kwargs:
|
|
157
|
+
method: The HTTP method to use when building a new request.
|
|
158
|
+
**kwargs: Either ``request_to_send`` by itself, or request
|
|
159
|
+
arguments such as ``url``, ``json``, ``params``, and headers.
|
|
142
160
|
|
|
143
161
|
Returns:
|
|
144
162
|
The HTTP response.
|
|
145
163
|
|
|
146
164
|
Raises:
|
|
147
|
-
|
|
165
|
+
ValueError: If neither a method nor a prebuilt request is provided,
|
|
166
|
+
or both request styles are mixed.
|
|
167
|
+
RuntimeError: If max retries are exceeded or a valid session gets
|
|
168
|
+
an unexpected 404 response.
|
|
148
169
|
"""
|
|
149
170
|
if "request_to_send" in kwargs and len(kwargs.keys()) != 1:
|
|
150
|
-
raise ValueError("
|
|
171
|
+
raise ValueError("Provide either a prebuilt request or arguments to build one.")
|
|
151
172
|
if not "request_to_send" in kwargs:
|
|
152
173
|
if method is None:
|
|
153
174
|
raise ValueError("If there's no prebuilt request, you must provide a method.")
|
|
154
175
|
|
|
155
|
-
|
|
176
|
+
url = kwargs["url"] if "url" in kwargs.keys() else kwargs["request_to_send"].url
|
|
177
|
+
if "json" in kwargs:
|
|
178
|
+
json_payload = kwargs["json"]
|
|
179
|
+
elif "request_to_send" in kwargs:
|
|
180
|
+
_req = kwargs["request_to_send"]
|
|
181
|
+
if _req.content:
|
|
182
|
+
try:
|
|
183
|
+
json_payload = json.loads(_req.content.decode())
|
|
184
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
185
|
+
json_payload = None
|
|
186
|
+
else:
|
|
187
|
+
json_payload = None
|
|
188
|
+
else:
|
|
189
|
+
json_payload = None
|
|
156
190
|
logging.info("Safe %s is running to %s%s\nJSON Payload: %s",
|
|
157
|
-
|
|
158
|
-
|
|
191
|
+
method, str(self.session.base_url), str(url),
|
|
192
|
+
json.dumps(json_payload) if json_payload is not None else "(no payload)")
|
|
159
193
|
async for attempt in async_range(self.max_retries):
|
|
160
194
|
if "request_to_send" in kwargs:
|
|
161
195
|
_request: Request = kwargs["request_to_send"]
|
|
@@ -167,7 +201,7 @@ class XUIClient:
|
|
|
167
201
|
if resp.status_code == 404:
|
|
168
202
|
now: float = datetime.now(UTC).timestamp()
|
|
169
203
|
if self.session_start is None or now - self.session_start > self.session_duration:
|
|
170
|
-
logging.info("Client
|
|
204
|
+
logging.info("Client (panel: %s) is not logged in, logging in...", self.panel_id or self.base_host)
|
|
171
205
|
await self.login()
|
|
172
206
|
continue
|
|
173
207
|
else:
|
|
@@ -291,15 +325,16 @@ class XUIClient:
|
|
|
291
325
|
if self.two_fac_secret:
|
|
292
326
|
payload["twoFactorCode"] = self.two_fac_secret.get_secret_value()
|
|
293
327
|
|
|
294
|
-
logging.info("Client is logging in
|
|
328
|
+
logging.info("Client is logging in (panel: %s)", self.panel_id or self.base_host)
|
|
295
329
|
resp = await self.session.post("/login", data=payload)
|
|
296
330
|
if resp.status_code == 200:
|
|
297
331
|
resp_json = resp.json()
|
|
298
|
-
if
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
else:
|
|
332
|
+
if "success" not in resp_json:
|
|
333
|
+
raise RuntimeError(f"Error: server returned a status code of {resp.status_code} but the response is not valid: {resp_json}")
|
|
334
|
+
if not resp_json["success"]:
|
|
302
335
|
raise ValueError("Error: wrong credentials (including status code) or failed login.")
|
|
336
|
+
self.session_start: float = (datetime.now(UTC).timestamp())
|
|
337
|
+
return
|
|
303
338
|
else:
|
|
304
339
|
raise RuntimeError(f"Error: server returned a status code of {resp.status_code}")
|
|
305
340
|
|
|
@@ -311,7 +346,7 @@ class XUIClient:
|
|
|
311
346
|
Returns:
|
|
312
347
|
Self: The XUIClient instance.
|
|
313
348
|
"""
|
|
314
|
-
logging.log(DEBUG, "Client connected
|
|
349
|
+
logging.log(DEBUG, "Client connected (panel: %s)", self.panel_id or self.base_url)
|
|
315
350
|
self.session = AsyncClient(base_url=self.base_url)
|
|
316
351
|
self.connected = True
|
|
317
352
|
return self
|
|
@@ -322,7 +357,8 @@ class XUIClient:
|
|
|
322
357
|
This method closes the async HTTP client session.
|
|
323
358
|
"""
|
|
324
359
|
if self._cache_cleaner_task is not None:
|
|
325
|
-
|
|
360
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
361
|
+
self._cache_cleaner_task.cancel("Panel is exiting.")
|
|
326
362
|
self.connected = False
|
|
327
363
|
|
|
328
364
|
if self.session is not None:
|
|
@@ -340,9 +376,10 @@ class XUIClient:
|
|
|
340
376
|
"""
|
|
341
377
|
self.connect()
|
|
342
378
|
await self.login()
|
|
343
|
-
self._cache_cleaner_task
|
|
344
|
-
self.
|
|
345
|
-
|
|
379
|
+
if not self._cache_cleaner_task:
|
|
380
|
+
self._cache_cleaner_task = asyncio.create_task(
|
|
381
|
+
self._clear_prod_inbound_cache_task(create_new=True), name=f"inb_cache_clearer_for_{self.base_url}"
|
|
382
|
+
)
|
|
346
383
|
return self
|
|
347
384
|
|
|
348
385
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
@@ -356,13 +393,13 @@ class XUIClient:
|
|
|
356
393
|
exc_val: The exception value, if an exception occurred.
|
|
357
394
|
exc_tb: The exception traceback, if an exception occurred.
|
|
358
395
|
"""
|
|
359
|
-
if exc_type is None or exc_type
|
|
360
|
-
logging.info("Client is disconnecting
|
|
396
|
+
if exc_type is None or exc_type is asyncio.exceptions.CancelledError:
|
|
397
|
+
logging.info("Client is disconnecting (panel: %s)", self.panel_id or self.base_host)
|
|
361
398
|
else:
|
|
362
399
|
logging.warning("Client is disconnecting due to an error (may be unrelated):"
|
|
363
400
|
"\n%s, with value %s\nStacktrace:%s",
|
|
364
401
|
exc_type, exc_val, exc_tb, exc_info=exc_tb)
|
|
365
|
-
print(f"Client is disconnecting: {self.base_host}")
|
|
402
|
+
print(f"Client is disconnecting: {self.panel_id or self.base_host}")
|
|
366
403
|
await self.disconnect()
|
|
367
404
|
return
|
|
368
405
|
|
|
@@ -391,24 +428,27 @@ class XUIClient:
|
|
|
391
428
|
|
|
392
429
|
return tuple(usable_inbounds)
|
|
393
430
|
|
|
394
|
-
async def
|
|
395
|
-
"""
|
|
431
|
+
async def _clear_prod_inbound_cache_task(self, *, create_new: bool = False):
|
|
432
|
+
"""Refresh the production inbound cache in the background.
|
|
396
433
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
434
|
+
The async context manager starts this loop after login. Each cycle
|
|
435
|
+
clears the cached production inbound list, repopulates it from the
|
|
436
|
+
panel, and then waits before refreshing again.
|
|
400
437
|
|
|
401
|
-
|
|
402
|
-
This method currently runs every 10 seconds. Please change the
|
|
403
|
-
timer from 5 to 60*60*24 in the code.
|
|
438
|
+
create_new param is kw-only and for people who know what they're doing, so they won't get the warning.
|
|
404
439
|
"""
|
|
440
|
+
if (self._cache_cleaner_task is not None) and (not create_new):
|
|
441
|
+
logging.warning("You're trying to create another cache cleaner task, which is a FaF (Fire-And-Forget)."
|
|
442
|
+
"Please destroy the previous task and set _cache_cleaner_task to None, if you know what you're doing.")
|
|
443
|
+
return
|
|
444
|
+
logging.info("Initializing cache cleaner task for %s", self.panel_id)
|
|
405
445
|
while self.connected:
|
|
406
446
|
self.get_production_inbounds.cache_clear()
|
|
407
|
-
await self.get_production_inbounds() #fill the cache
|
|
408
|
-
await asyncio.sleep(3600) #update every 1h
|
|
447
|
+
await self.get_production_inbounds() # fill the cache
|
|
448
|
+
await asyncio.sleep(3600) # update every 1h
|
|
409
449
|
|
|
410
450
|
#========================clients management========================
|
|
411
|
-
async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) ->
|
|
451
|
+
async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> list[ClientStats]:
|
|
412
452
|
"""Retrieve client information by Telegram ID.
|
|
413
453
|
|
|
414
454
|
This method fetches client information using the Telegram ID. If
|
|
@@ -436,25 +476,31 @@ class XUIClient:
|
|
|
436
476
|
|
|
437
477
|
async def create_and_add_prod_client(self, telegram_id: int, *,
|
|
438
478
|
additional_remark: str | None = None,
|
|
439
|
-
expiry_time: int=0,
|
|
479
|
+
expiry_time: int = 0,
|
|
440
480
|
exist_ok: bool = False
|
|
441
481
|
) -> list[Response]:
|
|
442
482
|
"""Create and add a production client.
|
|
443
483
|
|
|
444
484
|
This method creates a new client with the given Telegram ID and
|
|
445
485
|
adds it to the production inbounds. The client is configured with
|
|
446
|
-
default settings and the additional remark.
|
|
447
|
-
|
|
486
|
+
default settings and the additional remark. The subscription ID is
|
|
487
|
+
created by ``self.sub_gen``; by default this is
|
|
488
|
+
``util.default_sub_from_tgid``.
|
|
448
489
|
|
|
449
490
|
Args:
|
|
450
491
|
telegram_id: The Telegram ID of the client.
|
|
451
492
|
additional_remark: An optional additional remark for the client.
|
|
452
493
|
expiry_time: Expiry time in SECONDS as a UNIX timestamp.
|
|
453
|
-
exist_ok:
|
|
494
|
+
exist_ok: If True, return API responses even when the panel reports
|
|
495
|
+
a duplicate email.
|
|
454
496
|
|
|
455
497
|
Returns:
|
|
456
498
|
List[Response]: A list of responses from the server for each
|
|
457
499
|
inbound the client was added to.
|
|
500
|
+
|
|
501
|
+
Raises:
|
|
502
|
+
ClientEmailAlreadyExistsError: If a duplicate client is reported
|
|
503
|
+
and ``exist_ok`` is False.
|
|
458
504
|
"""
|
|
459
505
|
production_inbounds: tuple[Inbound, ...] = await self.get_production_inbounds()
|
|
460
506
|
|
|
@@ -466,15 +512,15 @@ class XUIClient:
|
|
|
466
512
|
custom_sub = self.sub_gen(telegram_id)
|
|
467
513
|
for inb in production_inbounds:
|
|
468
514
|
tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
|
|
469
|
-
client = SingleInboundClient
|
|
515
|
+
client = SingleInboundClient(
|
|
470
516
|
uuid=util.get_uuid_from_tgid(telegram_id),
|
|
471
517
|
flow="",
|
|
472
518
|
email=tmp_email,
|
|
473
519
|
limit_gb=0,
|
|
474
520
|
enable=True,
|
|
475
521
|
subscription_id=custom_sub,
|
|
476
|
-
comment=f"{additional_remark
|
|
477
|
-
expiry_time=expiry_time * 1000
|
|
522
|
+
comment=f"{additional_remark + ", " if additional_remark else ""}created at {datetime.now(UTC)}",
|
|
523
|
+
expiry_time=expiry_time * 1000,
|
|
478
524
|
)
|
|
479
525
|
tasks.append(asyncio.create_task(self.clients_end.add_client(client, inb.id)))
|
|
480
526
|
responses: list[Response] = await asyncio.gather(*tasks)
|
|
@@ -487,19 +533,29 @@ class XUIClient:
|
|
|
487
533
|
raise custom_exceptions.ClientEmailAlreadyExistsError(json_resp["msg"])
|
|
488
534
|
return responses
|
|
489
535
|
|
|
490
|
-
async def _find_client_in_inbound(self, client_uuid: str, inbound_id: int
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
536
|
+
async def _find_client_in_inbound(self, client_uuid: str, inbound_id: int,
|
|
537
|
+
use_cache=False) -> SingleInboundClient | None:
|
|
538
|
+
"""Note:
|
|
539
|
+
Cached production inbounds can be stale because the panel may be
|
|
540
|
+
changed by another actor. If a cached production inbound misses the
|
|
541
|
+
client, the production cache is cleared and fetched once more
|
|
542
|
+
before falling back to a direct inbound lookup.
|
|
543
|
+
"""
|
|
544
|
+
if use_cache:
|
|
545
|
+
prod_inbs = await self.get_production_inbounds()
|
|
546
|
+
prod_inb_index = None
|
|
547
|
+
for i, prod_inb in enumerate(prod_inbs): # see if inbound is production
|
|
548
|
+
if inbound_id == prod_inb.id:
|
|
549
|
+
prod_inb_index = i
|
|
550
|
+
|
|
551
|
+
if prod_inb_index is not None:
|
|
552
|
+
needed_inb: Inbound = prod_inbs[prod_inb_index]
|
|
553
|
+
result = get_inbound_in_client(client_uuid, needed_inb)
|
|
554
|
+
if result is None:
|
|
555
|
+
self.get_production_inbounds.cache_clear() # this means client is in a prod inbound but it's not refreshed
|
|
556
|
+
new_inb = (await self.get_production_inbounds())[prod_inb_index]
|
|
557
|
+
new_result = get_inbound_in_client(client_uuid, new_inb)
|
|
558
|
+
return new_result
|
|
503
559
|
|
|
504
560
|
inb = await self.inbounds_end.get_specific_inbound(inbound_id)
|
|
505
561
|
for client in inb.settings.clients:
|
|
@@ -507,19 +563,98 @@ class XUIClient:
|
|
|
507
563
|
return client
|
|
508
564
|
return None
|
|
509
565
|
|
|
510
|
-
async def
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
566
|
+
async def update_client_by_tgid_only(self, telegram_id: int, prod_only: bool, /, *,
|
|
567
|
+
security: str | None = None,
|
|
568
|
+
password: str | None = None,
|
|
569
|
+
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
|
|
570
|
+
limit_ip: int | None = None,
|
|
571
|
+
limit_gb: int | None = None,
|
|
572
|
+
expiry_time: int | None = None,
|
|
573
|
+
enable: bool | None = None,
|
|
574
|
+
sub_id: str | None = None,
|
|
575
|
+
comment: str | None = None,
|
|
576
|
+
verbose: bool = True
|
|
577
|
+
) -> list[Response]:
|
|
578
|
+
"""Update every matching client found by Telegram ID.
|
|
579
|
+
|
|
580
|
+
The client UUID is derived from ``telegram_id`` and searched across
|
|
581
|
+
either production inbounds or all inbounds. Only keyword arguments with
|
|
582
|
+
non-None values are applied to the client model before sending update
|
|
583
|
+
requests.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
telegram_id: Telegram ID used to derive the client UUID.
|
|
587
|
+
prod_only: If True, search only production inbounds. If False,
|
|
588
|
+
search every inbound returned by the panel.
|
|
589
|
+
security: New security setting.
|
|
590
|
+
password: New password.
|
|
591
|
+
flow: New VLESS flow value.
|
|
592
|
+
limit_ip: New simultaneous IP connection limit.
|
|
593
|
+
limit_gb: New traffic limit in gigabytes.
|
|
594
|
+
expiry_time: New expiry timestamp in seconds.
|
|
595
|
+
enable: New enabled state.
|
|
596
|
+
sub_id: New subscription ID.
|
|
597
|
+
comment: New client comment.
|
|
598
|
+
verbose: If True, warn when ``expiry_time`` looks like a duration
|
|
599
|
+
instead of a UNIX timestamp.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
Responses from each inbound where a matching client was updated.
|
|
603
|
+
"""
|
|
604
|
+
updates = {
|
|
605
|
+
"security": security,
|
|
606
|
+
"password": password,
|
|
607
|
+
"flow": flow,
|
|
608
|
+
"limit_ip": limit_ip,
|
|
609
|
+
"limit_gb": limit_gb,
|
|
610
|
+
"expiry_time": expiry_time,
|
|
611
|
+
"enable": enable,
|
|
612
|
+
"sub_id": sub_id,
|
|
613
|
+
"comment": comment,
|
|
614
|
+
}
|
|
615
|
+
# remove None values
|
|
616
|
+
updates = {k: v for k, v in updates.items() if v is not None}
|
|
617
|
+
|
|
618
|
+
if verbose:
|
|
619
|
+
if expiry_time and expiry_time < 1e9:
|
|
620
|
+
logging.warning("Warning: You're trying to update a client with expiry time %s. "
|
|
621
|
+
"You set it to expire before 2001, likely because you provided the DURATION. "
|
|
622
|
+
"You need to provide a TIMESTAMP. "
|
|
623
|
+
"If you want to disable this message, set verbose=false.",
|
|
624
|
+
expiry_time)
|
|
625
|
+
|
|
626
|
+
_to_exec: list[Task] = []
|
|
627
|
+
if prod_only:
|
|
628
|
+
self.get_production_inbounds.cache_clear()
|
|
629
|
+
inbounds = await self.get_production_inbounds()
|
|
630
|
+
else:
|
|
631
|
+
inbounds = await self.inbounds_end.get_all()
|
|
632
|
+
for inbound in inbounds:
|
|
633
|
+
found_client = util.get_inbound_in_client(util.get_uuid_from_tgid(telegram_id), inbound)
|
|
634
|
+
if found_client:
|
|
635
|
+
new_client = found_client.model_copy(update=updates, deep=True)
|
|
636
|
+
_to_exec.append(
|
|
637
|
+
asyncio.create_task(self.clients_end.request_update_client(
|
|
638
|
+
new_client, inbound.id, original_uuid=util.get_uuid_from_tgid(telegram_id)
|
|
639
|
+
))
|
|
640
|
+
)
|
|
641
|
+
responses = await asyncio.gather(*_to_exec)
|
|
642
|
+
return responses
|
|
643
|
+
|
|
644
|
+
async def update_client_by_tgid_inbid(self, telegram_id: int, inbound_id: int, /, *,
|
|
645
|
+
security: str | None = None,
|
|
646
|
+
password: str | None = None,
|
|
647
|
+
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
|
|
648
|
+
limit_ip: int | None = None,
|
|
649
|
+
limit_gb: int | None = None,
|
|
650
|
+
expiry_time: int | None = None,
|
|
651
|
+
enable: bool | None = None,
|
|
652
|
+
sub_id: str | None = None,
|
|
653
|
+
comment: str | None = None,
|
|
654
|
+
email: str | None = None,
|
|
655
|
+
verbose: bool = True) -> Response:
|
|
521
656
|
"""
|
|
522
|
-
Update a client in a specific inbound by Telegram ID.
|
|
657
|
+
Update a client in a specific inbound by Telegram ID. NOT optimized for multiple inbounds.
|
|
523
658
|
|
|
524
659
|
Args:
|
|
525
660
|
telegram_id: The Telegram ID of the client
|
|
@@ -533,6 +668,7 @@ class XUIClient:
|
|
|
533
668
|
enable: Whether the client is enabled (optional)
|
|
534
669
|
sub_id: Subscription ID (optional)
|
|
535
670
|
comment: Client comment/note (optional)
|
|
671
|
+
email: New client email (optional). USE WITH CAUTION BECAUSE THE PANEL WILL NOT TRACK THE NEW EMAIL.
|
|
536
672
|
|
|
537
673
|
Returns:
|
|
538
674
|
Response from the API
|
|
@@ -549,6 +685,7 @@ class XUIClient:
|
|
|
549
685
|
inbound_id=inbound_id, client_uuid=util.get_uuid_from_tgid(telegram_id),
|
|
550
686
|
security=security,
|
|
551
687
|
password=password,
|
|
688
|
+
email=email,
|
|
552
689
|
flow=flow,
|
|
553
690
|
limit_ip=limit_ip,
|
|
554
691
|
limit_gb=limit_gb,
|
|
@@ -583,12 +720,12 @@ class XUIClient:
|
|
|
583
720
|
List of Response objects from each deletion attempt
|
|
584
721
|
"""
|
|
585
722
|
production_inbounds = await self.get_production_inbounds()
|
|
586
|
-
|
|
587
|
-
|
|
723
|
+
_to_exec: list[Task] = []
|
|
588
724
|
for inbound in production_inbounds:
|
|
589
725
|
email = util.generate_email_from_tgid_inbid(telegram_id, inbound.id)
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
726
|
+
_to_exec.append(
|
|
727
|
+
asyncio.create_task(self.clients_end.delete_client_by_email(email, inbound.id))
|
|
728
|
+
)
|
|
729
|
+
logging.info("Clients of of tgid %s pending deletion", telegram_id)
|
|
730
|
+
responses = await asyncio.gather(*_to_exec)
|
|
594
731
|
return responses
|
|
@@ -7,7 +7,8 @@ import pydantic
|
|
|
7
7
|
from . import util
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
|
-
from api import XUIClient
|
|
10
|
+
from .api import XUIClient
|
|
11
|
+
|
|
11
12
|
|
|
12
13
|
class BaseModel(pydantic.BaseModel):
|
|
13
14
|
"""Base model for all 3X-UI API data models.
|
|
@@ -22,13 +23,12 @@ class BaseModel(pydantic.BaseModel):
|
|
|
22
23
|
ERROR_RETRIES: ClassVar[int] = 5
|
|
23
24
|
ERROR_RETRY_COOLDOWN: ClassVar[int] = 1
|
|
24
25
|
|
|
25
|
-
model_config = pydantic.ConfigDict(ignored_types=(cached_property, )
|
|
26
|
+
model_config = pydantic.ConfigDict(ignored_types=(cached_property,), validate_by_name=True, validate_by_alias=True)
|
|
26
27
|
|
|
27
28
|
# def model_post_init(self, context: Any, /) -> None:
|
|
28
29
|
# #print(f"Model {self.__class__}, {self} initialized")
|
|
29
30
|
# ...
|
|
30
31
|
|
|
31
|
-
|
|
32
32
|
@classmethod
|
|
33
33
|
def from_list(cls, args: List[Dict[str, Any]],
|
|
34
34
|
) -> List[Self]:
|
|
@@ -41,7 +41,7 @@ class BaseModel(pydantic.BaseModel):
|
|
|
41
41
|
A list of model instances initialized with the provided data.
|
|
42
42
|
|
|
43
43
|
Examples:
|
|
44
|
-
inbounds = Inbound.from_list([{"id": 1}, {"id": 2}]
|
|
44
|
+
inbounds = Inbound.from_list([{"id": 1}, {"id": 2}])
|
|
45
45
|
"""
|
|
46
46
|
return [cls(**obj) for obj in args]
|
|
47
47
|
|
|
@@ -50,7 +50,7 @@ class BaseModel(pydantic.BaseModel):
|
|
|
50
50
|
cls,
|
|
51
51
|
response: httpx.Response,
|
|
52
52
|
client: "XUIClient",
|
|
53
|
-
expect: list|dict,
|
|
53
|
+
expect: list | dict,
|
|
54
54
|
auto_retry: bool = True
|
|
55
55
|
) -> Union[Self, List[Self]]:
|
|
56
56
|
"""Create model instance(s) from an HTTP response.
|
|
@@ -86,6 +86,9 @@ class BaseModel(pydantic.BaseModel):
|
|
|
86
86
|
if expect is dict:
|
|
87
87
|
return cls(**obj)
|
|
88
88
|
else:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
if auto_retry:
|
|
90
|
+
req = response.request
|
|
91
|
+
new_resp = await client._safe_request(request_to_send=req)
|
|
92
|
+
return await cls.from_response(new_resp, client=client, expect=expect, auto_retry=False)
|
|
93
|
+
else:
|
|
94
|
+
raise util.DBLockedError("Failed to create model instance from response")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
|
|
2
|
+
class ClientEmailAlreadyExistsError(Exception):
|
|
3
|
+
"""Raised when the panel rejects a new client because its email exists."""
|
|
4
|
+
|
|
5
|
+
def __init__(self, *args):
|
|
6
|
+
if len(args) == 1:
|
|
7
|
+
super().__init__(args[0])
|
|
8
|
+
else:
|
|
9
|
+
super().__init__(*args)
|
|
10
|
+
|
|
11
|
+
class EmailNotExistsError(Exception):
|
|
12
|
+
"""Raised when a requested client email cannot be found on the panel."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *args):
|
|
15
|
+
if len(args) == 1:
|
|
16
|
+
super().__init__(args[0])
|
|
17
|
+
else:
|
|
18
|
+
super().__init__(*args)
|
|
19
|
+
|
|
20
|
+
class ClientDoesNotExistError(Exception):
|
|
21
|
+
"""Raised when a requested client UUID is absent from the target inbound."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *args):
|
|
24
|
+
if len(args) == 1:
|
|
25
|
+
super().__init__(args[0])
|
|
26
|
+
else:
|
|
27
|
+
super().__init__(*args)
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
3
|
from datetime import datetime, UTC
|
|
4
|
-
from typing import Generic, Literal, List, Dict
|
|
4
|
+
from typing import Generic, Literal, List, Dict, TypeVar, TYPE_CHECKING
|
|
5
5
|
|
|
6
6
|
from httpx import Response
|
|
7
|
-
from pydantic import ValidationError
|
|
8
|
-
from pydantic.main import ModelT
|
|
7
|
+
from pydantic import ValidationError, BaseModel
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
import pydantic
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .api import XUIClient
|
|
11
13
|
from .custom_exceptions import ClientDoesNotExistError
|
|
12
14
|
from .models import Inbound, SingleInboundClient, ClientStats, InboundClients, timestamp_seconds, ClientsSettings
|
|
13
15
|
from .util import JsonType
|
|
14
16
|
|
|
17
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
class BaseEndpoint(Generic[ModelT]):
|
|
17
21
|
"""Base class for API endpoint handlers.
|
|
@@ -120,7 +124,7 @@ class Inbounds(BaseEndpoint):
|
|
|
120
124
|
"""
|
|
121
125
|
_url = "panel/api/inbounds"
|
|
122
126
|
|
|
123
|
-
async def get_all(self) ->
|
|
127
|
+
async def get_all(self) -> list[Inbound]:
|
|
124
128
|
"""Retrieve all inbounds from the server.
|
|
125
129
|
|
|
126
130
|
Returns:
|
|
@@ -131,7 +135,7 @@ class Inbounds(BaseEndpoint):
|
|
|
131
135
|
inbounds = Inbound.from_list(json_resp)
|
|
132
136
|
return inbounds
|
|
133
137
|
|
|
134
|
-
async def get_specific_inbound(self, inbound_id) -> Inbound:
|
|
138
|
+
async def get_specific_inbound(self, inbound_id: int) -> Inbound:
|
|
135
139
|
"""Retrieve a specific inbound by ID.
|
|
136
140
|
|
|
137
141
|
Args:
|
|
@@ -159,7 +163,7 @@ class Clients(BaseEndpoint):
|
|
|
159
163
|
- /panel/api/inbounds/delDepletedClients/{inbound_id}
|
|
160
164
|
- /panel/api/inbounds/{inbound_id}/delClient/{email|uuid}
|
|
161
165
|
"""
|
|
162
|
-
_url = "panel/api/inbounds
|
|
166
|
+
_url = "panel/api/inbounds"
|
|
163
167
|
|
|
164
168
|
#although it's the same url, they should be differentiated
|
|
165
169
|
|
|
@@ -172,11 +176,11 @@ class Clients(BaseEndpoint):
|
|
|
172
176
|
Returns:
|
|
173
177
|
A ClientStats model instance with the client's statistics.
|
|
174
178
|
"""
|
|
175
|
-
endpoint = f"getClientTraffics/{email}"
|
|
179
|
+
endpoint = f"/getClientTraffics/{email}"
|
|
176
180
|
resp = await self._simple_get(endpoint)
|
|
177
181
|
return ClientStats.model_validate(resp)
|
|
178
182
|
|
|
179
|
-
async def get_client_with_uuid(self, uuid: str) ->
|
|
183
|
+
async def get_client_with_uuid(self, uuid: str) -> list[ClientStats]:
|
|
180
184
|
"""Retrieve client statistics by UUID.
|
|
181
185
|
|
|
182
186
|
Args:
|
|
@@ -185,7 +189,7 @@ class Clients(BaseEndpoint):
|
|
|
185
189
|
Returns:
|
|
186
190
|
A list of ClientStats model instances matching the UUID.
|
|
187
191
|
"""
|
|
188
|
-
endpoint = f"getClientTrafficsById/{uuid}"
|
|
192
|
+
endpoint = f"/getClientTrafficsById/{uuid}"
|
|
189
193
|
resp = await self._simple_get(endpoint)
|
|
190
194
|
client_stats = ClientStats.from_list(resp)
|
|
191
195
|
return client_stats
|
|
@@ -209,8 +213,8 @@ class Clients(BaseEndpoint):
|
|
|
209
213
|
ValueError: If a single client is provided without an inbound_id.
|
|
210
214
|
TypeError: If the client type is not supported.
|
|
211
215
|
"""
|
|
212
|
-
endpoint = f"addClient"
|
|
213
|
-
if isinstance(client,
|
|
216
|
+
endpoint = f"/addClient"
|
|
217
|
+
if isinstance(client, dict):
|
|
214
218
|
try:
|
|
215
219
|
final = InboundClients.model_validate(client)
|
|
216
220
|
except ValidationError:
|
|
@@ -222,6 +226,8 @@ class Clients(BaseEndpoint):
|
|
|
222
226
|
else:
|
|
223
227
|
raise ValueError("A single client was provided to be added but no parent inbound id")
|
|
224
228
|
elif isinstance(client, SingleInboundClient):
|
|
229
|
+
if not inbound_id:
|
|
230
|
+
raise ValueError("A single client was provided to be added but no parent inbound id")
|
|
225
231
|
final = InboundClients(id=inbound_id,
|
|
226
232
|
settings=ClientsSettings(clients=[client]))
|
|
227
233
|
elif isinstance(client, InboundClients):
|
|
@@ -236,33 +242,31 @@ class Clients(BaseEndpoint):
|
|
|
236
242
|
#YOU NEED TO PASS SETTINGS AS A STRING, NOT AS A DICT, YOU IDIOT!
|
|
237
243
|
return resp
|
|
238
244
|
|
|
239
|
-
async def
|
|
240
|
-
|
|
241
|
-
|
|
245
|
+
async def request_update_client(self, client: InboundClients | SingleInboundClient,
|
|
246
|
+
inbound_id: int | None = None,
|
|
247
|
+
*, original_uuid: str | None = None) -> Response:
|
|
242
248
|
"""Request to update an existing client.
|
|
243
249
|
|
|
244
250
|
Args:
|
|
245
251
|
client: The client data to update. Can be:
|
|
246
|
-
- A ClientUpdatePayload - Recommended (requires inbound_id)
|
|
247
252
|
- A SingleInboundClient (requires inbound_id)
|
|
248
253
|
- An InboundClients object (with one client)
|
|
249
254
|
inbound_id: The ID of the inbound the client belongs to.
|
|
250
|
-
Required if client is a SingleInboundClient
|
|
255
|
+
Required if client is a SingleInboundClient.
|
|
251
256
|
original_uuid: The original UUID of the client to update.
|
|
252
|
-
Required
|
|
257
|
+
Required by the 3X-UI update endpoint.
|
|
253
258
|
|
|
254
259
|
Returns:
|
|
255
260
|
The HTTP response from the API.
|
|
256
261
|
"""
|
|
257
262
|
if isinstance(client, SingleInboundClient):
|
|
258
263
|
client = InboundClients(id=inbound_id, settings=ClientsSettings(clients=[client]))
|
|
259
|
-
_endpoint = f"updateClient/{original_uuid
|
|
260
|
-
#we have to do this because if we do model.dump() it will return a Settings **OBJECT** which we DON'T want.
|
|
264
|
+
_endpoint = f"/updateClient/{original_uuid}"
|
|
265
|
+
# we have to do this because if we do model.dump() it will return a Settings **OBJECT** which we DON'T want.
|
|
261
266
|
resp = await self.client.safe_post(f"{self._url}{_endpoint}",
|
|
262
267
|
json=json.loads(client.model_dump_json(exclude_none=True, by_alias=True)))
|
|
263
268
|
return resp
|
|
264
269
|
|
|
265
|
-
|
|
266
270
|
async def update_single_client(self, inbound_id: int, client_uuid: str, *,
|
|
267
271
|
security: str | None = None,
|
|
268
272
|
password: str | None = None,
|
|
@@ -278,15 +282,15 @@ class Clients(BaseEndpoint):
|
|
|
278
282
|
"""Update an existing client's details.
|
|
279
283
|
|
|
280
284
|
Args:
|
|
281
|
-
client_uuid: The UUID of the original client.
|
|
282
285
|
inbound_id: The ID of the inbound the client belongs to.
|
|
286
|
+
client_uuid: The UUID of the original client.
|
|
283
287
|
security: New security settings (optional).
|
|
284
288
|
password: New password (optional).
|
|
285
289
|
flow: New flow settings (optional).
|
|
286
290
|
email: New email address (optional).
|
|
287
291
|
limit_ip: New IP limit (optional).
|
|
288
|
-
limit_gb: New
|
|
289
|
-
expiry_time: New expiry time (optional).
|
|
292
|
+
limit_gb: New traffic limit in gigabytes (optional).
|
|
293
|
+
expiry_time: New expiry time as a UNIX timestamp in seconds (optional).
|
|
290
294
|
enable: New enable status (optional).
|
|
291
295
|
sub_id: New subscription ID (optional).
|
|
292
296
|
comment: New comment (optional).
|
|
@@ -306,8 +310,9 @@ class Clients(BaseEndpoint):
|
|
|
306
310
|
raise ClientDoesNotExistError(f"The target inbound was checked but client {client_uuid} was not found.")
|
|
307
311
|
|
|
308
312
|
changes["updated_at"] = int(datetime.now(UTC).timestamp())
|
|
313
|
+
#TODO: see if model_copy actually does validation
|
|
309
314
|
updated = found_inbound.model_copy(update=changes)
|
|
310
|
-
resp = await self.
|
|
315
|
+
resp = await self.request_update_client(updated, inbound_id, original_uuid=client_uuid)
|
|
311
316
|
return resp
|
|
312
317
|
|
|
313
318
|
async def delete_expired_clients(self, inbound_id: int) -> Response:
|
|
@@ -319,7 +324,7 @@ class Clients(BaseEndpoint):
|
|
|
319
324
|
Returns:
|
|
320
325
|
The HTTP response from the API.
|
|
321
326
|
"""
|
|
322
|
-
_endpoint = f"delDepletedClients/"
|
|
327
|
+
_endpoint = f"/delDepletedClients/"
|
|
323
328
|
resp = await self.client.safe_post(f"{self._url}{_endpoint}{inbound_id}")
|
|
324
329
|
return resp
|
|
325
330
|
|
|
@@ -333,7 +338,7 @@ class Clients(BaseEndpoint):
|
|
|
333
338
|
Returns:
|
|
334
339
|
The HTTP response from the API.
|
|
335
340
|
"""
|
|
336
|
-
_endpoint = f"{inbound_id}/delClientByEmail/{email}"
|
|
341
|
+
_endpoint = f"/{inbound_id}/delClientByEmail/{email}"
|
|
337
342
|
resp = await self.client.safe_post(f"{self._url}{_endpoint}")
|
|
338
343
|
return resp
|
|
339
344
|
|
|
@@ -347,6 +352,6 @@ class Clients(BaseEndpoint):
|
|
|
347
352
|
Returns:
|
|
348
353
|
The HTTP response from the API.
|
|
349
354
|
"""
|
|
350
|
-
_endpoint = f"{inbound_id}/delClient/{uuid}"
|
|
355
|
+
_endpoint = f"/{inbound_id}/delClient/{uuid}"
|
|
351
356
|
resp = await self.client.safe_post(f"{self._url}{_endpoint}")
|
|
352
357
|
return resp
|
|
@@ -2,7 +2,6 @@ import json
|
|
|
2
2
|
from datetime import datetime, UTC
|
|
3
3
|
from typing import Union, TypeAlias, Any, Annotated, Literal, List, Dict, ClassVar
|
|
4
4
|
|
|
5
|
-
import pydantic
|
|
6
5
|
from pydantic import field_validator, Field, field_serializer
|
|
7
6
|
from typing_extensions import TypeVar
|
|
8
7
|
|
|
@@ -31,7 +30,8 @@ def exclude_if_none(field) -> bool:
|
|
|
31
30
|
_IntNone = TypeVar("_IntNone", bound=int | None)
|
|
32
31
|
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
# noinspection PyNestedDecorators
|
|
34
|
+
class SingleInboundClient(base_model.BaseModel):
|
|
35
35
|
"""Represents a single client within a VLESS/VMess inbound.
|
|
36
36
|
|
|
37
37
|
This model represents an individual VPN client with all its configuration
|
|
@@ -58,10 +58,11 @@ class SingleInboundClient(pydantic.BaseModel):
|
|
|
58
58
|
security: str = ""
|
|
59
59
|
password: str = ""
|
|
60
60
|
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"]
|
|
61
|
-
email:
|
|
61
|
+
email: str
|
|
62
62
|
limit_ip: Annotated[int, Field(alias="limitIp")] = 20
|
|
63
63
|
reset: int = 0
|
|
64
64
|
#Interestingly, the API expects this value to be called GB but it's actually bytes.
|
|
65
|
+
# I want the pythonic side to be in GB (hence why floats, i.e. 2.5GB), but the API expects bytes.
|
|
65
66
|
limit_gb: Annotated[int, Field(alias="totalGB")] = 0 # total flow
|
|
66
67
|
expiry_time: Annotated[timestamp_seconds, Field(alias="expiryTime")] = 0
|
|
67
68
|
enable: bool = True
|
|
@@ -75,26 +76,30 @@ class SingleInboundClient(pydantic.BaseModel):
|
|
|
75
76
|
default_factory=(lambda: int(datetime.now(UTC).timestamp())))
|
|
76
77
|
]
|
|
77
78
|
|
|
78
|
-
# noinspection PyNestedDecorators
|
|
79
79
|
@field_validator(TIME_FIELDS[0], *TIME_FIELDS[1:], mode="after")
|
|
80
80
|
@classmethod
|
|
81
81
|
def ensure_s_timestamp(cls, value: _IntNone) -> _IntNone:
|
|
82
82
|
return auto_ms_to_s_timestamp(value)
|
|
83
83
|
|
|
84
|
-
# noinspection PyNestedDecorators
|
|
85
84
|
@field_serializer(TIME_FIELDS[0], *TIME_FIELDS[1:])
|
|
86
85
|
@classmethod
|
|
87
|
-
def serialize_ms_timestamp(cls, value:
|
|
88
|
-
return auto_s_to_ms_timestamp(value)
|
|
86
|
+
def serialize_ms_timestamp(cls, value: int) -> int:
|
|
87
|
+
return auto_s_to_ms_timestamp(value)
|
|
89
88
|
|
|
90
|
-
# noinspection PyNestedDecorators
|
|
91
89
|
@field_serializer("limit_gb")
|
|
92
90
|
@classmethod
|
|
93
|
-
def serialize_total_gb(cls, value:
|
|
94
|
-
|
|
91
|
+
def serialize_total_gb(cls, value: int) -> int:
|
|
92
|
+
#API expects an integer of bytes.
|
|
93
|
+
return value * (1024 ** 3)
|
|
95
94
|
|
|
95
|
+
@field_validator("limit_gb", mode="after")
|
|
96
|
+
@classmethod
|
|
97
|
+
def parse_total_gb(cls, value: int) -> int:
|
|
98
|
+
#Python wants an int/float of GB.
|
|
99
|
+
return value // (1024 ** 3)
|
|
96
100
|
|
|
97
|
-
|
|
101
|
+
|
|
102
|
+
class ClientsSettings(base_model.BaseModel):
|
|
98
103
|
"""Settings container for inbound clients.
|
|
99
104
|
|
|
100
105
|
Attributes:
|
|
@@ -103,10 +108,10 @@ class ClientsSettings(pydantic.BaseModel):
|
|
|
103
108
|
clients: list[SingleInboundClient]
|
|
104
109
|
decryption: Annotated[str, Field(exclude_if=lambda x: x == "none")] = "none"
|
|
105
110
|
encryption: Annotated[str, Field(exclude_if=lambda x: x == "none")] = "none"
|
|
106
|
-
fallbacks: Annotated[list|None, Field(exclude_if=exclude_if_none)] = None
|
|
111
|
+
fallbacks: Annotated[list | None, Field(exclude_if=exclude_if_none)] = None
|
|
107
112
|
|
|
108
113
|
|
|
109
|
-
class InboundClients(
|
|
114
|
+
class InboundClients(base_model.BaseModel):
|
|
110
115
|
"""Represents a collection of clients for an inbound connection.
|
|
111
116
|
|
|
112
117
|
This model is used when adding or updating clients on an inbound,
|
|
@@ -187,6 +192,7 @@ class InboundClients(pydantic.BaseModel):
|
|
|
187
192
|
# external_proxy: Annotated[list[ExternalProxy], Field(alias="externalProxy")]
|
|
188
193
|
# tcp_settings: TCPSettings
|
|
189
194
|
|
|
195
|
+
# noinspection PyNestedDecorators
|
|
190
196
|
class ClientStats(base_model.BaseModel):
|
|
191
197
|
"""Statistics and configuration for a VPN client.
|
|
192
198
|
|
|
@@ -203,10 +209,12 @@ class ClientStats(base_model.BaseModel):
|
|
|
203
209
|
up: Total uploaded bytes.
|
|
204
210
|
down: Total downloaded bytes.
|
|
205
211
|
allTime: Total bytes transferred (up + down).
|
|
206
|
-
expiryTime: Client expiry time as UNIX timestamp in
|
|
212
|
+
expiryTime: Client expiry time as a UNIX timestamp in seconds on the
|
|
213
|
+
Python model, serialized to milliseconds for the API.
|
|
207
214
|
total: Total data limit in bytes.
|
|
208
215
|
reset: Counter for traffic resets.
|
|
209
|
-
lastOnline: UNIX timestamp of last connection
|
|
216
|
+
lastOnline: UNIX timestamp of last connection in seconds on the
|
|
217
|
+
Python model, serialized to milliseconds for the API.
|
|
210
218
|
"""
|
|
211
219
|
TIME_FIELDS: ClassVar[List[str]] = ["expiryTime", "lastOnline"]
|
|
212
220
|
id: int
|
|
@@ -223,17 +231,18 @@ class ClientStats(base_model.BaseModel):
|
|
|
223
231
|
reset: int
|
|
224
232
|
lastOnline: timestamp_seconds
|
|
225
233
|
|
|
226
|
-
@classmethod
|
|
227
234
|
@field_validator(TIME_FIELDS[0], *TIME_FIELDS[1:], mode="after")
|
|
235
|
+
@classmethod
|
|
228
236
|
def ensure_s_timestamp(cls, value: int) -> int:
|
|
229
237
|
return auto_ms_to_s_timestamp(value)
|
|
230
238
|
|
|
231
|
-
@classmethod
|
|
232
239
|
@field_serializer(TIME_FIELDS[0], *TIME_FIELDS[1:])
|
|
240
|
+
@classmethod
|
|
233
241
|
def serialize_ms_timestamp(cls, value: int) -> int:
|
|
234
242
|
return auto_s_to_ms_timestamp(value)
|
|
235
243
|
|
|
236
244
|
|
|
245
|
+
# noinspection PyNestedDecorators
|
|
237
246
|
class Inbound(base_model.BaseModel):
|
|
238
247
|
"""Represents a VPN inbound connection configuration.
|
|
239
248
|
|
|
@@ -274,7 +283,8 @@ class Inbound(base_model.BaseModel):
|
|
|
274
283
|
clientStats: list[ClientStats] | None
|
|
275
284
|
listen: str
|
|
276
285
|
port: int
|
|
277
|
-
|
|
286
|
+
#TODO: add trojan, shadowsocks, wireguard back in when they are supported by the API
|
|
287
|
+
protocol: Literal["vless", "vmess"] #"trojan", "shadowsocks", "wireguard"] # note: there are some "deprecated" like wireguard
|
|
278
288
|
settings: ClientsSettings # JSON packed value, stringified
|
|
279
289
|
streamSettings: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
|
|
280
290
|
tag: str
|
|
@@ -285,7 +295,7 @@ class Inbound(base_model.BaseModel):
|
|
|
285
295
|
def parse_settings(cls, value: str) -> ClientsSettings:
|
|
286
296
|
if value == "":
|
|
287
297
|
return ClientsSettings(clients=[])
|
|
288
|
-
return ClientsSettings.model_validate_json(value, by_alias=True, extra="
|
|
298
|
+
return ClientsSettings.model_validate_json(value, by_alias=True, extra="ignore")
|
|
289
299
|
|
|
290
300
|
@field_serializer("settings")
|
|
291
301
|
@classmethod
|
|
@@ -315,12 +325,12 @@ class Inbound(base_model.BaseModel):
|
|
|
315
325
|
return ""
|
|
316
326
|
return json.dumps(value, ensure_ascii=False)
|
|
317
327
|
|
|
318
|
-
@classmethod
|
|
319
328
|
@field_validator(TIME_FIELDS[0], *TIME_FIELDS[1:], mode="after")
|
|
329
|
+
@classmethod
|
|
320
330
|
def ensure_s_timestamp(cls, value: int) -> int:
|
|
321
331
|
return auto_ms_to_s_timestamp(value)
|
|
322
332
|
|
|
323
|
-
@classmethod
|
|
324
333
|
@field_serializer(TIME_FIELDS[0], *TIME_FIELDS[1:])
|
|
334
|
+
@classmethod
|
|
325
335
|
def serialize_ms_timestamp(cls, value: int) -> int:
|
|
326
336
|
return auto_s_to_ms_timestamp(value)
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
"""Utility functions and helpers for the
|
|
1
|
+
"""Utility functions and helpers for the python_3xui package.
|
|
2
2
|
|
|
3
|
-
This module provides common utilities used across the API
|
|
3
|
+
This module provides common utilities used across the 3X-UI API wrapper,
|
|
4
|
+
including:
|
|
4
5
|
- String conversion helpers (camelCase to snake_case)
|
|
5
6
|
- Async generators
|
|
6
7
|
- Base64 encoding utilities
|
|
7
8
|
- Telegram ID-based UUID/email generation
|
|
8
9
|
- Response validation
|
|
9
10
|
"""
|
|
11
|
+
from __future__ import annotations
|
|
10
12
|
|
|
11
13
|
import asyncio
|
|
12
14
|
import base64
|
|
@@ -14,10 +16,13 @@ import logging
|
|
|
14
16
|
import random
|
|
15
17
|
import re
|
|
16
18
|
from datetime import UTC, datetime, tzinfo
|
|
17
|
-
from typing import TypeAlias, Union, Dict, Any, List
|
|
19
|
+
from typing import TYPE_CHECKING, TypeAlias, Union, Dict, Any, List
|
|
18
20
|
|
|
19
21
|
import httpx
|
|
20
22
|
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from python_3xui.models import Inbound, SingleInboundClient
|
|
25
|
+
|
|
21
26
|
JsonType: TypeAlias = Union[Dict[Any, Any], List[Any]]
|
|
22
27
|
|
|
23
28
|
_RE_CAMEL_TO_SNAKE1 = re.compile("(.)([A-Z][a-z]+)")
|
|
@@ -57,13 +62,12 @@ async def async_range(start: int, stop: int|None=None, step: int=1):
|
|
|
57
62
|
Yields:
|
|
58
63
|
int: The next value in the range sequence.
|
|
59
64
|
"""
|
|
60
|
-
if stop:
|
|
65
|
+
if stop is not None:
|
|
61
66
|
range_ = range(start, stop, step)
|
|
62
67
|
else:
|
|
63
68
|
range_ = range(start)
|
|
64
69
|
for i in range_:
|
|
65
70
|
yield i
|
|
66
|
-
await asyncio.sleep(0)
|
|
67
71
|
|
|
68
72
|
|
|
69
73
|
def base64_from_string(string: str, omit_trailing_equals: bool = False) -> str:
|
|
@@ -71,10 +75,12 @@ def base64_from_string(string: str, omit_trailing_equals: bool = False) -> str:
|
|
|
71
75
|
|
|
72
76
|
Args:
|
|
73
77
|
string: The input string to encode.
|
|
74
|
-
omit_trailing_equals:
|
|
78
|
+
omit_trailing_equals: Reserved for callers that do not want trailing
|
|
79
|
+
``=`` padding. The current implementation returns standard padded
|
|
80
|
+
base64 output.
|
|
75
81
|
|
|
76
82
|
Returns:
|
|
77
|
-
The base64
|
|
83
|
+
The base64-encoded string.
|
|
78
84
|
"""
|
|
79
85
|
return base64.b64encode(bytes(str(string).encode("utf-8"))).decode()
|
|
80
86
|
|
|
@@ -124,7 +130,15 @@ def get_uuid_from_tgid(telegram_id: int, fixed: bool = True) -> str:
|
|
|
124
130
|
return f"{now.year}{mon}{day}-{hr}{mn}-1111-1111-{resid}"
|
|
125
131
|
|
|
126
132
|
|
|
127
|
-
def random_string(length: int):
|
|
133
|
+
def random_string(length: int) -> str:
|
|
134
|
+
"""Generate a random alphanumeric string.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
length: Number of characters to generate.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
A string made from ASCII letters and digits.
|
|
141
|
+
"""
|
|
128
142
|
s = "".join([random.choice(
|
|
129
143
|
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") for _ in range(length)
|
|
130
144
|
])
|
|
@@ -145,6 +159,22 @@ def generate_random_email(length: int = 8) -> str:
|
|
|
145
159
|
return random_string(length)
|
|
146
160
|
|
|
147
161
|
|
|
162
|
+
def get_inbound_in_client(client_uuid: str, inbound: Inbound) -> SingleInboundClient|None:
|
|
163
|
+
"""Find a client inside an inbound by UUID.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
client_uuid: UUID of the client to find.
|
|
167
|
+
inbound: Inbound model whose client list should be searched.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The matching client, or None if the inbound does not contain it.
|
|
171
|
+
"""
|
|
172
|
+
for client in inbound.settings.clients:
|
|
173
|
+
if client.uuid == client_uuid:
|
|
174
|
+
return client
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
148
178
|
def generate_email_from_tgid_inbid(telegram_id: int, /, inbound_id: int) -> str:
|
|
149
179
|
"""Generate a deterministic email from Telegram ID and inbound ID.
|
|
150
180
|
|
|
@@ -165,7 +195,7 @@ def generate_email_from_tgid_inbid(telegram_id: int, /, inbound_id: int) -> str:
|
|
|
165
195
|
return f"TG{telegram_id}IB{inbound_id}"
|
|
166
196
|
|
|
167
197
|
|
|
168
|
-
def generate_new_subscription(length: int = 16):
|
|
198
|
+
def generate_new_subscription(length: int = 16) -> str:
|
|
169
199
|
"""Generate a random subscription ID.
|
|
170
200
|
|
|
171
201
|
Args:
|
|
@@ -180,7 +210,7 @@ def generate_new_subscription(length: int = 16):
|
|
|
180
210
|
return random_string(length)
|
|
181
211
|
|
|
182
212
|
|
|
183
|
-
async def check_xui_response(response:
|
|
213
|
+
async def check_xui_response(response: dict | httpx.Response) -> str:
|
|
184
214
|
"""Validate a 3X-UI API response.
|
|
185
215
|
|
|
186
216
|
Checks if the response follows the expected 3X-UI API format with
|
|
@@ -209,7 +239,7 @@ async def check_xui_response(response: JsonType | httpx.Response) -> str:
|
|
|
209
239
|
json_resp = response.json()
|
|
210
240
|
else:
|
|
211
241
|
json_resp = response
|
|
212
|
-
|
|
242
|
+
|
|
213
243
|
if len(json_resp) == 3:
|
|
214
244
|
if tuple(json_resp.keys()) == ("success", "msg", "obj"):
|
|
215
245
|
success: bool = json_resp["success"]
|
|
@@ -285,4 +315,4 @@ def auto_ms_to_s_timestamp(ms_or_s: int) -> int:
|
|
|
285
315
|
|
|
286
316
|
def datetime_now_ms(tzinfo: tzinfo|None=UTC) -> int:
|
|
287
317
|
"""Get the current time as a UNIX timestamp in milliseconds."""
|
|
288
|
-
return int(datetime.now(tzinfo).timestamp()
|
|
318
|
+
return int(datetime.now(tzinfo).timestamp() * 1000)
|
{python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_non_idempotent_endpoints_clients.py
RENAMED
|
@@ -33,7 +33,7 @@ class TestClientsEndpoint:
|
|
|
33
33
|
# Try to find a suitable inbound (preferably with PROD_STRING in remark)
|
|
34
34
|
test_inbound = None
|
|
35
35
|
for inbound in all_inbounds:
|
|
36
|
-
if xui_client.PROD_STRING.search(inbound.remark
|
|
36
|
+
if xui_client.PROD_STRING.search(inbound.remark):
|
|
37
37
|
test_inbound = inbound
|
|
38
38
|
break
|
|
39
39
|
|
|
@@ -129,7 +129,7 @@ class TestXUIClientHelpers:
|
|
|
129
129
|
before = await xui_client.clients_end.get_client_with_email(email)
|
|
130
130
|
assert before.enable is True, "Newly created client should start enabled"
|
|
131
131
|
|
|
132
|
-
resp = await xui_client.
|
|
132
|
+
resp = await xui_client.update_client_by_tgid_inbid(
|
|
133
133
|
_TGID_UPDATE, target_inbound.id, verbose=False, sub_id=_TEST_SUB_ID,
|
|
134
134
|
)
|
|
135
135
|
assert resp.status_code == 200
|
python_3xui-0.0.9/README.md
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
<h1>Hi! This is my example python 3x-ui wrapper!</h1>
|
|
2
|
-
<p>I'm not expecting much to be honest, so please feel free to fork it if I abandon the project and you need it!</p>
|
|
3
|
-
<p>Also, if you REALLY want it I can give you the ownership if I step down, you can find my email in the pyproject.toml (I don't check it that much but trust me I do)</p>
|
|
4
|
-
|
|
5
|
-
<h2>0.0.9 Release Notes</h2>
|
|
6
|
-
<ul>
|
|
7
|
-
<li>Fix _request_update_client for it to actually work and NOT create "zombies"</li>
|
|
8
|
-
<li>DTO un-split because fields reset when not provided, so full inbounds must be fetched</li>
|
|
9
|
-
<li>New method: update_client_by_tgid</li>
|
|
10
|
-
<li>Fixed test suite</li>
|
|
11
|
-
<li>Fix from_response and from_list</li>
|
|
12
|
-
<li>Remove obsolete and useless client fields from models</li>
|
|
13
|
-
<li>Inbound settings actually get parsed properly into ClientsSettings</li>
|
|
14
|
-
<li>New asyncio task management so they won't get destroyed when GCed</li>
|
|
15
|
-
<li>XUIClient async_lru cache now binds to event loop at runtime, not in initialization</li>
|
|
16
|
-
</ul>
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
class ClientEmailAlreadyExistsError(Exception):
|
|
3
|
-
def __init__(self, *args):
|
|
4
|
-
super().__init__(args[0] if len(args) == 0 else args)
|
|
5
|
-
|
|
6
|
-
class EmailNotExistsError(Exception):
|
|
7
|
-
def __init__(self, *args):
|
|
8
|
-
super().__init__(args[0] if len(args) == 0 else args)
|
|
9
|
-
|
|
10
|
-
class ClientDoesNotExistError(Exception):
|
|
11
|
-
def __init__(self, *args):
|
|
12
|
-
super().__init__(args[0] if len(args) == 0 else args)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_non_idempotent_endpoints_inbounds.py
RENAMED
|
File without changes
|