Python-3xui 0.0.8.post1__tar.gz → 0.0.9__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.8.post1 → python_3xui-0.0.9}/PKG-INFO +13 -4
- python_3xui-0.0.9/README.md +16 -0
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/pyproject.toml +59 -58
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/__init__.py +4 -2
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/api.py +89 -37
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/base_model.py +9 -12
- python_3xui-0.0.9/python_3xui/custom_exceptions.py +12 -0
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/endpoints.py +46 -48
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/models.py +54 -29
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/util.py +13 -13
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/conftest.py +0 -7
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/gather_response_stubs.py +2 -2
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/test_endpoints_inbounds.py +10 -4
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/test_non_idempotent_endpoints_clients.py +31 -32
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/test_non_idempotent_endpoints_inbounds.py +1 -0
- python_3xui-0.0.9/tests/test_xuiclient_helpers.py +188 -0
- python_3xui-0.0.8.post1/README.md +0 -8
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/.gitignore +0 -0
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/LICENSE +0 -0
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/pytest.ini +0 -0
- {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/test_endpoints_clients.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: Python-3xui
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.9
|
|
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
|
|
@@ -12,11 +12,12 @@ Classifier: Intended Audience :: Developers
|
|
|
12
12
|
Classifier: Operating System :: OS Independent
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
14
|
Requires-Python: >=3.11
|
|
15
|
-
Requires-Dist: async-lru~=2.
|
|
15
|
+
Requires-Dist: async-lru~=2.3.0
|
|
16
16
|
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
|
|
20
21
|
Provides-Extra: testing
|
|
21
22
|
Requires-Dist: pytest; extra == 'testing'
|
|
22
23
|
Requires-Dist: pytest-asyncio; extra == 'testing'
|
|
@@ -28,7 +29,15 @@ Description-Content-Type: text/markdown
|
|
|
28
29
|
<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>
|
|
29
30
|
<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>
|
|
30
31
|
|
|
31
|
-
<h2>0.0.
|
|
32
|
+
<h2>0.0.9 Release Notes</h2>
|
|
32
33
|
<ul>
|
|
33
|
-
<li>
|
|
34
|
+
<li>Fix _request_update_client for it to actually work and NOT create "zombies"</li>
|
|
35
|
+
<li>DTO un-split because fields reset when not provided, so full inbounds must be fetched</li>
|
|
36
|
+
<li>New method: update_client_by_tgid</li>
|
|
37
|
+
<li>Fixed test suite</li>
|
|
38
|
+
<li>Fix from_response and from_list</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>
|
|
34
43
|
</ul>
|
|
@@ -0,0 +1,16 @@
|
|
|
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,59 +1,60 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "Python-3xui"
|
|
3
|
-
version = "0.0.
|
|
4
|
-
authors = [
|
|
5
|
-
{ name="JustMe_001", email="justme001.causation755@passinbox.com" },
|
|
6
|
-
]
|
|
7
|
-
description = "3x-ui wrapper for python"
|
|
8
|
-
readme = "README.md"
|
|
9
|
-
|
|
10
|
-
requires-python = ">=3.11"
|
|
11
|
-
classifiers = [
|
|
12
|
-
"Programming Language :: Python :: 3",
|
|
13
|
-
"Operating System :: OS Independent",
|
|
14
|
-
|
|
15
|
-
"Development Status :: 3 - Alpha",
|
|
16
|
-
"Intended Audience :: Developers",
|
|
17
|
-
]
|
|
18
|
-
|
|
19
|
-
license = "Apache-2.0"
|
|
20
|
-
license-files = ["LICEN[CS]E*"]
|
|
21
|
-
|
|
22
|
-
dependencies = [
|
|
23
|
-
"pydantic ~= 2.12.5, < 3",
|
|
24
|
-
"httpx ~=0.28.1",
|
|
25
|
-
"dotenv ~= 0.9.9",
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
"/tests
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
".
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
1
|
+
[project]
|
|
2
|
+
name = "Python-3xui"
|
|
3
|
+
version = "0.0.9"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name="JustMe_001", email="justme001.causation755@passinbox.com" },
|
|
6
|
+
]
|
|
7
|
+
description = "3x-ui wrapper for python"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
license = "Apache-2.0"
|
|
20
|
+
license-files = ["LICEN[CS]E*"]
|
|
21
|
+
|
|
22
|
+
dependencies = [
|
|
23
|
+
"pydantic ~= 2.12.5, < 3",
|
|
24
|
+
"httpx ~=0.28.1",
|
|
25
|
+
"dotenv ~= 0.9.9",
|
|
26
|
+
"python-dotenv",
|
|
27
|
+
"async_lru ~= 2.3.0",
|
|
28
|
+
"pyotp ~= 2.9.0"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
testing = ["requests", "pytest", "pytest-asyncio", "pytest-dependency"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/Artem-Potapov/3x-py"
|
|
38
|
+
Issues = "https://github.com/Artem-Potapov/3x-py/issues"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling >= 1.26"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = [
|
|
48
|
+
"python_3xui/*.py",
|
|
49
|
+
"/tests/*.py",
|
|
50
|
+
"/tests/pytest.ini"
|
|
51
|
+
]
|
|
52
|
+
exclude = [
|
|
53
|
+
"requirements.txt",
|
|
54
|
+
"main.py",
|
|
55
|
+
".env"
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
[[tool.hatch.envs.hatch-test.matrix]]
|
|
59
60
|
python = ["3.13", "3.12", "3.11"]
|
|
@@ -1,22 +1,24 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import json
|
|
2
3
|
import logging
|
|
3
4
|
import re
|
|
4
|
-
import
|
|
5
|
+
from asyncio import Task
|
|
5
6
|
from collections.abc import Sequence, Mapping
|
|
6
|
-
from inspect import isawaitable
|
|
7
|
-
from logging import DEBUG
|
|
8
|
-
from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal, Callable, Awaitable, Coroutine
|
|
9
7
|
from datetime import datetime, UTC
|
|
8
|
+
from inspect import iscoroutinefunction
|
|
9
|
+
from logging import DEBUG
|
|
10
|
+
from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal, Callable, Awaitable, overload
|
|
10
11
|
|
|
12
|
+
import httpx
|
|
11
13
|
import pyotp
|
|
12
|
-
from httpx import Response, AsyncClient
|
|
13
14
|
from async_lru import alru_cache
|
|
14
|
-
import
|
|
15
|
-
import
|
|
15
|
+
from httpx import Response, AsyncClient, Request
|
|
16
|
+
from pydantic import SecretStr
|
|
16
17
|
|
|
18
|
+
from . import custom_exceptions
|
|
17
19
|
from . import util
|
|
18
20
|
from .models import Inbound, SingleInboundClient, ClientStats
|
|
19
|
-
from .util import JsonType, async_range
|
|
21
|
+
from .util import JsonType, async_range
|
|
20
22
|
|
|
21
23
|
DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
|
|
22
24
|
PrimitiveData = Optional[Union[str, int, float, bool]]
|
|
@@ -42,9 +44,6 @@ class XUIClient:
|
|
|
42
44
|
This class provides methods for authenticating with the 3X-UI panel,
|
|
43
45
|
managing sessions, and performing operations on inbounds and clients.
|
|
44
46
|
|
|
45
|
-
The client implements a singleton pattern to ensure only one instance
|
|
46
|
-
exists at a time.
|
|
47
|
-
|
|
48
47
|
Attributes:
|
|
49
48
|
PROD_STRING: String used to identify production inbounds.
|
|
50
49
|
session: The async HTTP client session.
|
|
@@ -63,13 +62,13 @@ class XUIClient:
|
|
|
63
62
|
clients_end: Clients endpoint handler.
|
|
64
63
|
inbounds_end: Inbounds endpoint handler.
|
|
65
64
|
"""
|
|
66
|
-
_instance = None
|
|
67
65
|
|
|
68
66
|
def __init__(self, base_website: str, base_port: int, base_path: str,
|
|
69
67
|
*, username: str | None = None, password: str | None = None,
|
|
70
68
|
two_fac_code: str | None = None, session_duration: int = 3600,
|
|
71
69
|
custom_prod_string: str = "testing",
|
|
72
|
-
|
|
70
|
+
max_retries: int = 5, retry_delay = 1,
|
|
71
|
+
custom_sub_generator: Callable[[int], str]|Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
|
|
73
72
|
) -> None:
|
|
74
73
|
"""Initialize the XUIClient.
|
|
75
74
|
|
|
@@ -94,28 +93,43 @@ class XUIClient:
|
|
|
94
93
|
self.session_duration: int = session_duration
|
|
95
94
|
self.xui_username: str | None = username
|
|
96
95
|
self.xui_password: str | None = password
|
|
97
|
-
self.two_fac_secret:
|
|
96
|
+
self.two_fac_secret: SecretStr | None = SecretStr(two_fac_code) if two_fac_code is not None else None
|
|
98
97
|
self.totp: pyotp.TOTP | None = None
|
|
99
|
-
self.max_retries: int =
|
|
100
|
-
self.retry_delay: int =
|
|
98
|
+
self.max_retries: int = max_retries
|
|
99
|
+
self.retry_delay: int = retry_delay
|
|
101
100
|
self.sub_gen = custom_sub_generator
|
|
102
101
|
# endpoints
|
|
103
102
|
self.server_end = endpoints.Server(self)
|
|
104
103
|
self.clients_end = endpoints.Clients(self)
|
|
105
104
|
self.inbounds_end = endpoints.Inbounds(self)
|
|
105
|
+
# Per-instance cache wrapper. Using a class-level @alru_cache() on the underlying coroutine binds the cache to
|
|
106
|
+
# the first event loop that touches it (see async_lru._check_loop), which breaks any caller that creates
|
|
107
|
+
# a new XUIClient on a fresh loop (e.g. each pytest-asyncio test). Building the wrapper here gives every
|
|
108
|
+
# instance its own cache bound to its own loop.
|
|
109
|
+
self.get_production_inbounds = alru_cache(maxsize=128)(self._get_production_inbounds_impl)
|
|
110
|
+
self._cache_cleaner_task: Task|None = None
|
|
106
111
|
#init self.totp
|
|
107
112
|
if self.two_fac_secret:
|
|
108
|
-
if self.two_fac_secret.
|
|
113
|
+
if len(self.two_fac_secret.get_secret_value()) <= 8:
|
|
109
114
|
print("WARNING: You seem to have entered a 2FA **code**, not a 2FA secret."
|
|
110
115
|
"Although entering the secret is dangerous, there is no other way to provide a consistent way"
|
|
111
116
|
"for continuous login. This code will only work for this specific login.")
|
|
112
117
|
self.totp = None
|
|
113
118
|
else:
|
|
114
|
-
self.totp = pyotp.TOTP(self.two_fac_secret)
|
|
119
|
+
self.totp = pyotp.TOTP(self.two_fac_secret.get_secret_value())
|
|
115
120
|
|
|
116
121
|
#========================request stuffs========================
|
|
122
|
+
@overload
|
|
123
|
+
async def _safe_request(self, *, request_to_send: httpx.Request) -> Response:
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
@overload
|
|
127
|
+
async def _safe_request(self, method: Literal["get", "post", "patch", "delete", "put"],
|
|
128
|
+
**kwargs) -> Response:
|
|
129
|
+
...
|
|
130
|
+
|
|
117
131
|
async def _safe_request(self,
|
|
118
|
-
method: Literal["get", "post", "patch", "delete", "put"],
|
|
132
|
+
method: Literal["get", "post", "patch", "delete", "put"]|None=None,
|
|
119
133
|
**kwargs) -> Response:
|
|
120
134
|
"""Execute an HTTP request with automatic retry on database lock.
|
|
121
135
|
|
|
@@ -132,9 +146,23 @@ class XUIClient:
|
|
|
132
146
|
Raises:
|
|
133
147
|
RuntimeError: If max retries exceeded or session is invalid.
|
|
134
148
|
"""
|
|
135
|
-
|
|
149
|
+
if "request_to_send" in kwargs and len(kwargs.keys()) != 1:
|
|
150
|
+
raise ValueError("It's either a predetermined a request or args to build your own.")
|
|
151
|
+
if not "request_to_send" in kwargs:
|
|
152
|
+
if method is None:
|
|
153
|
+
raise ValueError("If there's no prebuilt request, you must provide a method.")
|
|
154
|
+
|
|
155
|
+
#FIXME: make it also extract JSON out of a ready request
|
|
156
|
+
logging.info("Safe %s is running to %s%s\nJSON Payload: %s",
|
|
157
|
+
method, str(self.session.base_url), str(kwargs["url"]) or kwargs["request_to_send"].url,
|
|
158
|
+
json.dumps(kwargs["json"]) if "json" in kwargs.keys() else "(no payload)")
|
|
136
159
|
async for attempt in async_range(self.max_retries):
|
|
137
|
-
|
|
160
|
+
if "request_to_send" in kwargs:
|
|
161
|
+
_request: Request = kwargs["request_to_send"]
|
|
162
|
+
resp = await self.session.send(_request)
|
|
163
|
+
else:
|
|
164
|
+
# noinspection PyTypeChecker
|
|
165
|
+
resp = await self.session.request(method, **kwargs)
|
|
138
166
|
if resp.status_code // 100 != 2: #because it can return either 201 or 202
|
|
139
167
|
if resp.status_code == 404:
|
|
140
168
|
now: float = datetime.now(UTC).timestamp()
|
|
@@ -261,7 +289,7 @@ class XUIClient:
|
|
|
261
289
|
payload["twoFactorCode"] = self.totp.now()
|
|
262
290
|
else:
|
|
263
291
|
if self.two_fac_secret:
|
|
264
|
-
payload["twoFactorCode"] = self.two_fac_secret
|
|
292
|
+
payload["twoFactorCode"] = self.two_fac_secret.get_secret_value()
|
|
265
293
|
|
|
266
294
|
logging.info("Client is logging in with IP/Domain: %s", self.base_host)
|
|
267
295
|
resp = await self.session.post("/login", data=payload)
|
|
@@ -293,8 +321,12 @@ class XUIClient:
|
|
|
293
321
|
|
|
294
322
|
This method closes the async HTTP client session.
|
|
295
323
|
"""
|
|
324
|
+
if self._cache_cleaner_task is not None:
|
|
325
|
+
self._cache_cleaner_task.cancel("Panel is exiting.")
|
|
296
326
|
self.connected = False
|
|
297
|
-
|
|
327
|
+
|
|
328
|
+
if self.session is not None:
|
|
329
|
+
await self.session.aclose()
|
|
298
330
|
|
|
299
331
|
async def __aenter__(self) -> Self:
|
|
300
332
|
"""Enter the async context manager.
|
|
@@ -308,7 +340,9 @@ class XUIClient:
|
|
|
308
340
|
"""
|
|
309
341
|
self.connect()
|
|
310
342
|
await self.login()
|
|
311
|
-
asyncio.create_task(
|
|
343
|
+
self._cache_cleaner_task = asyncio.create_task(
|
|
344
|
+
self.clear_prod_inbound_cache(), name=f"inb_cache_clearer_for_{self.base_url}"
|
|
345
|
+
)
|
|
312
346
|
return self
|
|
313
347
|
|
|
314
348
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
@@ -327,21 +361,22 @@ class XUIClient:
|
|
|
327
361
|
else:
|
|
328
362
|
logging.warning("Client is disconnecting due to an error (may be unrelated):"
|
|
329
363
|
"\n%s, with value %s\nStacktrace:%s",
|
|
330
|
-
exc_type, exc_val, exc_tb)
|
|
364
|
+
exc_type, exc_val, exc_tb, exc_info=exc_tb)
|
|
331
365
|
print(f"Client is disconnecting: {self.base_host}")
|
|
332
366
|
await self.disconnect()
|
|
333
367
|
return
|
|
334
368
|
|
|
335
369
|
#========================inbound management========================
|
|
336
|
-
|
|
337
|
-
async def get_production_inbounds(self) -> Tuple[Inbound, ...]:
|
|
370
|
+
async def _get_production_inbounds_impl(self) -> tuple[Inbound, ...]:
|
|
338
371
|
"""Retrieve production inbounds.
|
|
339
372
|
|
|
340
373
|
This method fetches all inbounds and filters them based on the
|
|
341
|
-
production string. It is
|
|
374
|
+
production string. It is wrapped in a per-instance ``alru_cache``
|
|
375
|
+
in ``__init__`` and exposed as ``get_production_inbounds``; do not
|
|
376
|
+
call this method directly outside of that wrapper.
|
|
342
377
|
|
|
343
378
|
Returns:
|
|
344
|
-
|
|
379
|
+
tuple[Inbound]: A list of production inbounds.
|
|
345
380
|
|
|
346
381
|
Raises:
|
|
347
382
|
RuntimeError: If no production inbounds are found.
|
|
@@ -404,7 +439,6 @@ class XUIClient:
|
|
|
404
439
|
expiry_time: int=0,
|
|
405
440
|
exist_ok: bool = False
|
|
406
441
|
) -> list[Response]:
|
|
407
|
-
#TODO: add exist_ok flag
|
|
408
442
|
"""Create and add a production client.
|
|
409
443
|
|
|
410
444
|
This method creates a new client with the given Telegram ID and
|
|
@@ -422,11 +456,11 @@ class XUIClient:
|
|
|
422
456
|
List[Response]: A list of responses from the server for each
|
|
423
457
|
inbound the client was added to.
|
|
424
458
|
"""
|
|
425
|
-
production_inbounds:
|
|
459
|
+
production_inbounds: tuple[Inbound, ...] = await self.get_production_inbounds()
|
|
426
460
|
|
|
427
461
|
tasks = []
|
|
428
462
|
custom_sub: str
|
|
429
|
-
if
|
|
463
|
+
if iscoroutinefunction(self.sub_gen):
|
|
430
464
|
custom_sub = await self.sub_gen(telegram_id)
|
|
431
465
|
else:
|
|
432
466
|
custom_sub = self.sub_gen(telegram_id)
|
|
@@ -450,8 +484,29 @@ class XUIClient:
|
|
|
450
484
|
json_resp = resp.json()
|
|
451
485
|
if "duplicate email" in json_resp["msg"].lower():
|
|
452
486
|
logging.error("ERROR: Client already exists and exist_ok not set: %s", json_resp["msg"])
|
|
487
|
+
raise custom_exceptions.ClientEmailAlreadyExistsError(json_resp["msg"])
|
|
453
488
|
return responses
|
|
454
489
|
|
|
490
|
+
async def _find_client_in_inbound(self, client_uuid: str, inbound_id: int) -> SingleInboundClient|None:
|
|
491
|
+
prod_inbs = await self.get_production_inbounds() #check production first since they're all cached
|
|
492
|
+
prod_inb_index = None
|
|
493
|
+
for i, prod_inb in enumerate(prod_inbs): # see if inbound is production
|
|
494
|
+
if inbound_id == prod_inb.id:
|
|
495
|
+
prod_inb_index = i
|
|
496
|
+
|
|
497
|
+
if prod_inb_index is not None:
|
|
498
|
+
needed_inb: Inbound = prod_inbs[prod_inb_index]
|
|
499
|
+
for client in needed_inb.settings.clients:
|
|
500
|
+
if client.uuid == client_uuid:
|
|
501
|
+
return client
|
|
502
|
+
self.get_production_inbounds.cache_clear() # this means client is in a prod inbound but it's not refreshed
|
|
503
|
+
|
|
504
|
+
inb = await self.inbounds_end.get_specific_inbound(inbound_id)
|
|
505
|
+
for client in inb.settings.clients:
|
|
506
|
+
if client.uuid == client_uuid:
|
|
507
|
+
return client
|
|
508
|
+
return None
|
|
509
|
+
|
|
455
510
|
async def update_client_by_tgid(self, telegram_id: int, inbound_id: int, /, *,
|
|
456
511
|
security: str | None = None,
|
|
457
512
|
password: str | None = None,
|
|
@@ -482,10 +537,8 @@ class XUIClient:
|
|
|
482
537
|
Returns:
|
|
483
538
|
Response from the API
|
|
484
539
|
"""
|
|
485
|
-
email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
|
|
486
|
-
existing_client = await self.clients_end.get_client_with_email(email)
|
|
487
540
|
if verbose:
|
|
488
|
-
if expiry_time < 1e9:
|
|
541
|
+
if expiry_time and expiry_time < 1e9:
|
|
489
542
|
logging.warning("Warning: You're trying to update a client with expiry time %s. "
|
|
490
543
|
"You set it to expire before 2001, likely because you provided the DURATION. "
|
|
491
544
|
"You need to provide a TIMESTAMP. "
|
|
@@ -493,8 +546,7 @@ class XUIClient:
|
|
|
493
546
|
expiry_time)
|
|
494
547
|
|
|
495
548
|
resp = await self.clients_end.update_single_client(
|
|
496
|
-
|
|
497
|
-
inbound_id,
|
|
549
|
+
inbound_id=inbound_id, client_uuid=util.get_uuid_from_tgid(telegram_id),
|
|
498
550
|
security=security,
|
|
499
551
|
password=password,
|
|
500
552
|
flow=flow,
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, overload, Self, ClassVar, Annotated, Literal, Callable
|
|
1
|
+
from functools import cached_property
|
|
2
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Union, Self, ClassVar
|
|
4
3
|
|
|
5
|
-
import pydantic
|
|
6
4
|
import httpx
|
|
7
|
-
|
|
5
|
+
import pydantic
|
|
8
6
|
|
|
9
|
-
from . import models
|
|
10
7
|
from . import util
|
|
11
8
|
|
|
12
9
|
if TYPE_CHECKING:
|
|
@@ -34,13 +31,11 @@ class BaseModel(pydantic.BaseModel):
|
|
|
34
31
|
|
|
35
32
|
@classmethod
|
|
36
33
|
def from_list(cls, args: List[Dict[str, Any]],
|
|
37
|
-
client: "XUIClient"
|
|
38
34
|
) -> List[Self]:
|
|
39
35
|
"""Create a list of model instances from a list of dictionaries.
|
|
40
36
|
|
|
41
37
|
Args:
|
|
42
38
|
args: A list of dictionaries containing model data.
|
|
43
|
-
client: The XUIClient instance to associate with each model.
|
|
44
39
|
|
|
45
40
|
Returns:
|
|
46
41
|
A list of model instances initialized with the provided data.
|
|
@@ -83,12 +78,14 @@ class BaseModel(pydantic.BaseModel):
|
|
|
83
78
|
inbounds = await Inbound.from_response(response, client, list)
|
|
84
79
|
"""
|
|
85
80
|
json_resp: util.JsonType = response.json()
|
|
86
|
-
valid = util.check_xui_response(json_resp)
|
|
81
|
+
valid = await util.check_xui_response(json_resp)
|
|
87
82
|
if valid == "OK":
|
|
88
83
|
obj = json_resp["obj"]
|
|
89
84
|
if expect is list:
|
|
90
|
-
return cls.from_list(obj
|
|
85
|
+
return cls.from_list(obj)
|
|
91
86
|
if expect is dict:
|
|
92
|
-
return cls(**obj
|
|
87
|
+
return cls(**obj)
|
|
93
88
|
else:
|
|
94
|
-
|
|
89
|
+
req = response.request
|
|
90
|
+
new_resp = await client._safe_request(request_to_send=req)
|
|
91
|
+
return await cls.from_response(new_resp, client=client, expect=expect, auto_retry=False)
|
|
@@ -0,0 +1,12 @@
|
|
|
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)
|