Python-3xui 0.0.8__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 → python_3xui-0.0.9}/PKG-INFO +13 -6
- python_3xui-0.0.9/README.md +16 -0
- {python_3xui-0.0.8 → python_3xui-0.0.9}/pyproject.toml +60 -59
- {python_3xui-0.0.8 → python_3xui-0.0.9}/python_3xui/__init__.py +4 -2
- {python_3xui-0.0.8 → python_3xui-0.0.9}/python_3xui/api.py +106 -45
- {python_3xui-0.0.8 → 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 → python_3xui-0.0.9}/python_3xui/endpoints.py +46 -48
- {python_3xui-0.0.8 → python_3xui-0.0.9}/python_3xui/models.py +54 -29
- {python_3xui-0.0.8 → python_3xui-0.0.9}/python_3xui/util.py +14 -14
- {python_3xui-0.0.8 → python_3xui-0.0.9}/tests/conftest.py +0 -7
- {python_3xui-0.0.8 → python_3xui-0.0.9}/tests/gather_response_stubs.py +2 -2
- {python_3xui-0.0.8 → python_3xui-0.0.9}/tests/test_endpoints_inbounds.py +10 -4
- {python_3xui-0.0.8 → python_3xui-0.0.9}/tests/test_non_idempotent_endpoints_clients.py +32 -33
- {python_3xui-0.0.8 → 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/README.md +0 -10
- {python_3xui-0.0.8 → python_3xui-0.0.9}/.gitignore +0 -0
- {python_3xui-0.0.8 → python_3xui-0.0.9}/LICENSE +0 -0
- {python_3xui-0.0.8 → python_3xui-0.0.9}/tests/pytest.ini +0 -0
- {python_3xui-0.0.8 → 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,9 +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>
|
|
35
|
-
<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>
|
|
36
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
|
-
|
|
59
|
-
|
|
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]]
|
|
60
|
+
python = ["3.13", "3.12", "3.11"]
|
|
@@ -1,21 +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 logging import DEBUG
|
|
7
|
-
from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal
|
|
8
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
|
|
9
11
|
|
|
12
|
+
import httpx
|
|
10
13
|
import pyotp
|
|
11
|
-
from httpx import Response, AsyncClient
|
|
12
14
|
from async_lru import alru_cache
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
+
from httpx import Response, AsyncClient, Request
|
|
16
|
+
from pydantic import SecretStr
|
|
15
17
|
|
|
18
|
+
from . import custom_exceptions
|
|
16
19
|
from . import util
|
|
17
20
|
from .models import Inbound, SingleInboundClient, ClientStats
|
|
18
|
-
from .util import JsonType, async_range
|
|
21
|
+
from .util import JsonType, async_range
|
|
19
22
|
|
|
20
23
|
DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
|
|
21
24
|
PrimitiveData = Optional[Union[str, int, float, bool]]
|
|
@@ -41,9 +44,6 @@ class XUIClient:
|
|
|
41
44
|
This class provides methods for authenticating with the 3X-UI panel,
|
|
42
45
|
managing sessions, and performing operations on inbounds and clients.
|
|
43
46
|
|
|
44
|
-
The client implements a singleton pattern to ensure only one instance
|
|
45
|
-
exists at a time.
|
|
46
|
-
|
|
47
47
|
Attributes:
|
|
48
48
|
PROD_STRING: String used to identify production inbounds.
|
|
49
49
|
session: The async HTTP client session.
|
|
@@ -62,12 +62,14 @@ class XUIClient:
|
|
|
62
62
|
clients_end: Clients endpoint handler.
|
|
63
63
|
inbounds_end: Inbounds endpoint handler.
|
|
64
64
|
"""
|
|
65
|
-
_instance = None
|
|
66
65
|
|
|
67
66
|
def __init__(self, base_website: str, base_port: int, base_path: str,
|
|
68
67
|
*, username: str | None = None, password: str | None = None,
|
|
69
68
|
two_fac_code: str | None = None, session_duration: int = 3600,
|
|
70
|
-
custom_prod_string: str = "testing"
|
|
69
|
+
custom_prod_string: str = "testing",
|
|
70
|
+
max_retries: int = 5, retry_delay = 1,
|
|
71
|
+
custom_sub_generator: Callable[[int], str]|Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
|
|
72
|
+
) -> None:
|
|
71
73
|
"""Initialize the XUIClient.
|
|
72
74
|
|
|
73
75
|
Args:
|
|
@@ -91,27 +93,43 @@ class XUIClient:
|
|
|
91
93
|
self.session_duration: int = session_duration
|
|
92
94
|
self.xui_username: str | None = username
|
|
93
95
|
self.xui_password: str | None = password
|
|
94
|
-
self.two_fac_secret:
|
|
96
|
+
self.two_fac_secret: SecretStr | None = SecretStr(two_fac_code) if two_fac_code is not None else None
|
|
95
97
|
self.totp: pyotp.TOTP | None = None
|
|
96
|
-
self.max_retries: int =
|
|
97
|
-
self.retry_delay: int =
|
|
98
|
+
self.max_retries: int = max_retries
|
|
99
|
+
self.retry_delay: int = retry_delay
|
|
100
|
+
self.sub_gen = custom_sub_generator
|
|
98
101
|
# endpoints
|
|
99
102
|
self.server_end = endpoints.Server(self)
|
|
100
103
|
self.clients_end = endpoints.Clients(self)
|
|
101
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
|
|
102
111
|
#init self.totp
|
|
103
112
|
if self.two_fac_secret:
|
|
104
|
-
if self.two_fac_secret.
|
|
113
|
+
if len(self.two_fac_secret.get_secret_value()) <= 8:
|
|
105
114
|
print("WARNING: You seem to have entered a 2FA **code**, not a 2FA secret."
|
|
106
115
|
"Although entering the secret is dangerous, there is no other way to provide a consistent way"
|
|
107
116
|
"for continuous login. This code will only work for this specific login.")
|
|
108
117
|
self.totp = None
|
|
109
118
|
else:
|
|
110
|
-
self.totp = pyotp.TOTP(self.two_fac_secret)
|
|
119
|
+
self.totp = pyotp.TOTP(self.two_fac_secret.get_secret_value())
|
|
111
120
|
|
|
112
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
|
+
|
|
113
131
|
async def _safe_request(self,
|
|
114
|
-
method: Literal["get", "post", "patch", "delete", "put"],
|
|
132
|
+
method: Literal["get", "post", "patch", "delete", "put"]|None=None,
|
|
115
133
|
**kwargs) -> Response:
|
|
116
134
|
"""Execute an HTTP request with automatic retry on database lock.
|
|
117
135
|
|
|
@@ -128,9 +146,23 @@ class XUIClient:
|
|
|
128
146
|
Raises:
|
|
129
147
|
RuntimeError: If max retries exceeded or session is invalid.
|
|
130
148
|
"""
|
|
131
|
-
|
|
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)")
|
|
132
159
|
async for attempt in async_range(self.max_retries):
|
|
133
|
-
|
|
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)
|
|
134
166
|
if resp.status_code // 100 != 2: #because it can return either 201 or 202
|
|
135
167
|
if resp.status_code == 404:
|
|
136
168
|
now: float = datetime.now(UTC).timestamp()
|
|
@@ -257,7 +289,7 @@ class XUIClient:
|
|
|
257
289
|
payload["twoFactorCode"] = self.totp.now()
|
|
258
290
|
else:
|
|
259
291
|
if self.two_fac_secret:
|
|
260
|
-
payload["twoFactorCode"] = self.two_fac_secret
|
|
292
|
+
payload["twoFactorCode"] = self.two_fac_secret.get_secret_value()
|
|
261
293
|
|
|
262
294
|
logging.info("Client is logging in with IP/Domain: %s", self.base_host)
|
|
263
295
|
resp = await self.session.post("/login", data=payload)
|
|
@@ -289,8 +321,12 @@ class XUIClient:
|
|
|
289
321
|
|
|
290
322
|
This method closes the async HTTP client session.
|
|
291
323
|
"""
|
|
324
|
+
if self._cache_cleaner_task is not None:
|
|
325
|
+
self._cache_cleaner_task.cancel("Panel is exiting.")
|
|
292
326
|
self.connected = False
|
|
293
|
-
|
|
327
|
+
|
|
328
|
+
if self.session is not None:
|
|
329
|
+
await self.session.aclose()
|
|
294
330
|
|
|
295
331
|
async def __aenter__(self) -> Self:
|
|
296
332
|
"""Enter the async context manager.
|
|
@@ -304,7 +340,9 @@ class XUIClient:
|
|
|
304
340
|
"""
|
|
305
341
|
self.connect()
|
|
306
342
|
await self.login()
|
|
307
|
-
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
|
+
)
|
|
308
346
|
return self
|
|
309
347
|
|
|
310
348
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
@@ -323,21 +361,22 @@ class XUIClient:
|
|
|
323
361
|
else:
|
|
324
362
|
logging.warning("Client is disconnecting due to an error (may be unrelated):"
|
|
325
363
|
"\n%s, with value %s\nStacktrace:%s",
|
|
326
|
-
exc_type, exc_val, exc_tb)
|
|
364
|
+
exc_type, exc_val, exc_tb, exc_info=exc_tb)
|
|
327
365
|
print(f"Client is disconnecting: {self.base_host}")
|
|
328
366
|
await self.disconnect()
|
|
329
367
|
return
|
|
330
368
|
|
|
331
369
|
#========================inbound management========================
|
|
332
|
-
|
|
333
|
-
async def get_production_inbounds(self) -> Tuple[Inbound, ...]:
|
|
370
|
+
async def _get_production_inbounds_impl(self) -> tuple[Inbound, ...]:
|
|
334
371
|
"""Retrieve production inbounds.
|
|
335
372
|
|
|
336
373
|
This method fetches all inbounds and filters them based on the
|
|
337
|
-
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.
|
|
338
377
|
|
|
339
378
|
Returns:
|
|
340
|
-
|
|
379
|
+
tuple[Inbound]: A list of production inbounds.
|
|
341
380
|
|
|
342
381
|
Raises:
|
|
343
382
|
RuntimeError: If no production inbounds are found.
|
|
@@ -400,7 +439,6 @@ class XUIClient:
|
|
|
400
439
|
expiry_time: int=0,
|
|
401
440
|
exist_ok: bool = False
|
|
402
441
|
) -> list[Response]:
|
|
403
|
-
#TODO: add exist_ok flag
|
|
404
442
|
"""Create and add a production client.
|
|
405
443
|
|
|
406
444
|
This method creates a new client with the given Telegram ID and
|
|
@@ -418,9 +456,14 @@ class XUIClient:
|
|
|
418
456
|
List[Response]: A list of responses from the server for each
|
|
419
457
|
inbound the client was added to.
|
|
420
458
|
"""
|
|
421
|
-
production_inbounds:
|
|
459
|
+
production_inbounds: tuple[Inbound, ...] = await self.get_production_inbounds()
|
|
422
460
|
|
|
423
461
|
tasks = []
|
|
462
|
+
custom_sub: str
|
|
463
|
+
if iscoroutinefunction(self.sub_gen):
|
|
464
|
+
custom_sub = await self.sub_gen(telegram_id)
|
|
465
|
+
else:
|
|
466
|
+
custom_sub = self.sub_gen(telegram_id)
|
|
424
467
|
for inb in production_inbounds:
|
|
425
468
|
tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
|
|
426
469
|
client = SingleInboundClient.model_construct(
|
|
@@ -429,7 +472,7 @@ class XUIClient:
|
|
|
429
472
|
email=tmp_email,
|
|
430
473
|
limit_gb=0,
|
|
431
474
|
enable=True,
|
|
432
|
-
subscription_id=
|
|
475
|
+
subscription_id=custom_sub,
|
|
433
476
|
comment=f"{additional_remark}, created at {datetime.now(UTC)}",
|
|
434
477
|
expiry_time=expiry_time * 1000
|
|
435
478
|
)
|
|
@@ -441,8 +484,29 @@ class XUIClient:
|
|
|
441
484
|
json_resp = resp.json()
|
|
442
485
|
if "duplicate email" in json_resp["msg"].lower():
|
|
443
486
|
logging.error("ERROR: Client already exists and exist_ok not set: %s", json_resp["msg"])
|
|
487
|
+
raise custom_exceptions.ClientEmailAlreadyExistsError(json_resp["msg"])
|
|
444
488
|
return responses
|
|
445
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
|
+
|
|
446
510
|
async def update_client_by_tgid(self, telegram_id: int, inbound_id: int, /, *,
|
|
447
511
|
security: str | None = None,
|
|
448
512
|
password: str | None = None,
|
|
@@ -460,23 +524,21 @@ class XUIClient:
|
|
|
460
524
|
Args:
|
|
461
525
|
telegram_id: The Telegram ID of the client
|
|
462
526
|
inbound_id: The ID of the inbound where the client exists
|
|
463
|
-
security: Client security setting
|
|
464
|
-
password: Client password
|
|
465
|
-
flow: VLESS flow type
|
|
466
|
-
limit_ip: IP connection limit
|
|
467
|
-
limit_gb: Data limit in GB
|
|
468
|
-
expiry_time: Client expiry time (UNIX timestamp)
|
|
469
|
-
enable: Whether the client is enabled
|
|
470
|
-
sub_id: Subscription ID
|
|
471
|
-
comment: Client comment/note
|
|
527
|
+
security: Client security setting (optional)
|
|
528
|
+
password: Client password (optional)
|
|
529
|
+
flow: VLESS flow type (optional)
|
|
530
|
+
limit_ip: IP connection limit (optional)
|
|
531
|
+
limit_gb: Data limit in GB (optional)
|
|
532
|
+
expiry_time: Client expiry time (UNIX timestamp) (optional)
|
|
533
|
+
enable: Whether the client is enabled (optional)
|
|
534
|
+
sub_id: Subscription ID (optional)
|
|
535
|
+
comment: Client comment/note (optional)
|
|
472
536
|
|
|
473
537
|
Returns:
|
|
474
538
|
Response from the API
|
|
475
539
|
"""
|
|
476
|
-
email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
|
|
477
|
-
existing_client = await self.clients_end.get_client_with_email(email)
|
|
478
540
|
if verbose:
|
|
479
|
-
if expiry_time < 1e9:
|
|
541
|
+
if expiry_time and expiry_time < 1e9:
|
|
480
542
|
logging.warning("Warning: You're trying to update a client with expiry time %s. "
|
|
481
543
|
"You set it to expire before 2001, likely because you provided the DURATION. "
|
|
482
544
|
"You need to provide a TIMESTAMP. "
|
|
@@ -484,8 +546,7 @@ class XUIClient:
|
|
|
484
546
|
expiry_time)
|
|
485
547
|
|
|
486
548
|
resp = await self.clients_end.update_single_client(
|
|
487
|
-
|
|
488
|
-
inbound_id,
|
|
549
|
+
inbound_id=inbound_id, client_uuid=util.get_uuid_from_tgid(telegram_id),
|
|
489
550
|
security=security,
|
|
490
551
|
password=password,
|
|
491
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)
|