Python-3xui 0.0.9.post3__tar.gz → 0.0.10.post2__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.post3 → python_3xui-0.0.10.post2}/PKG-INFO +6 -8
- python_3xui-0.0.10.post2/README.md +10 -0
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/pyproject.toml +2 -2
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/python_3xui/__init__.py +1 -1
- python_3xui-0.0.10.post2/python_3xui/api.py +531 -0
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/python_3xui/custom_exceptions.py +2 -1
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/python_3xui/endpoints.py +69 -31
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/python_3xui/models.py +3 -2
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/python_3xui/util.py +16 -9
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/tests/conftest.py +0 -1
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/tests/gather_response_stubs.py +2 -2
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/tests/test_non_idempotent_endpoints_clients.py +16 -16
- python_3xui-0.0.10.post2/tests/test_non_idempotent_endpoints_inbounds.py +118 -0
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/tests/test_xuiclient_helpers.py +7 -7
- python_3xui-0.0.9.post3/README.md +0 -12
- python_3xui-0.0.9.post3/python_3xui/api.py +0 -731
- python_3xui-0.0.9.post3/tests/test_non_idempotent_endpoints_inbounds.py +0 -125
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/.gitignore +0 -0
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/LICENSE +0 -0
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/python_3xui/base_model.py +0 -0
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/tests/pytest.ini +0 -0
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/tests/test_endpoints_clients.py +0 -0
- {python_3xui-0.0.9.post3 → python_3xui-0.0.10.post2}/tests/test_endpoints_inbounds.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.10.post2
|
|
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
|
|
@@ -13,10 +13,10 @@ Classifier: Operating System :: OS Independent
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
14
|
Requires-Python: >=3.11
|
|
15
15
|
Requires-Dist: async-lru~=2.3.0
|
|
16
|
-
Requires-Dist: dotenv~=0.9.9
|
|
17
16
|
Requires-Dist: httpx~=0.28.1
|
|
18
17
|
Requires-Dist: pydantic<3,~=2.12.5
|
|
19
18
|
Requires-Dist: pyotp~=2.9.0
|
|
19
|
+
Requires-Dist: python-dotenv==1.2.2
|
|
20
20
|
Provides-Extra: testing
|
|
21
21
|
Requires-Dist: pytest; extra == 'testing'
|
|
22
22
|
Requires-Dist: pytest-asyncio; extra == 'testing'
|
|
@@ -28,11 +28,9 @@ Description-Content-Type: text/markdown
|
|
|
28
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>
|
|
29
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>
|
|
30
30
|
|
|
31
|
-
<h2>0.0.
|
|
31
|
+
<h2>0.0.10 Release Notes</h2>
|
|
32
32
|
<ul>
|
|
33
|
-
<li>HOTFIX:
|
|
34
|
-
<li>
|
|
35
|
-
<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>
|
|
33
|
+
<li>HOTFIX: make models.SingleInboundClient default flow "", because turns out panel can not return it because of zombification...</li>
|
|
34
|
+
<li>Add a custom uuid generator for XUIClient that <i>defaults</i> to method in util but you can make your own!</li>
|
|
35
|
+
<li>Uncomplicate self.sub_gen into self._resolve_sub</li>
|
|
38
36
|
</ul>
|
|
@@ -0,0 +1,10 @@
|
|
|
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.10 Release Notes</h2>
|
|
6
|
+
<ul>
|
|
7
|
+
<li>HOTFIX: make models.SingleInboundClient default flow "", because turns out panel can not return it because of zombification...</li>
|
|
8
|
+
<li>Add a custom uuid generator for XUIClient that <i>defaults</i> to method in util but you can make your own!</li>
|
|
9
|
+
<li>Uncomplicate self.sub_gen into self._resolve_sub</li>
|
|
10
|
+
</ul>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "Python-3xui"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.10r2"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name="JustMe_001", email="justme001.causation755@passinbox.com" },
|
|
6
6
|
]
|
|
@@ -22,7 +22,7 @@ license-files = ["LICEN[CS]E*"]
|
|
|
22
22
|
dependencies = [
|
|
23
23
|
"pydantic ~= 2.12.5, < 3",
|
|
24
24
|
"httpx ~=0.28.1",
|
|
25
|
-
"dotenv
|
|
25
|
+
"python-dotenv==1.2.2",
|
|
26
26
|
"async_lru ~= 2.3.0",
|
|
27
27
|
"pyotp ~= 2.9.0"
|
|
28
28
|
]
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Awaitable, Callable, List, Literal, Self, overload
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pyotp
|
|
7
|
+
from httpx import AsyncClient, Response
|
|
8
|
+
from pydantic import SecretStr
|
|
9
|
+
|
|
10
|
+
from . import endpoints
|
|
11
|
+
from . import util
|
|
12
|
+
from .models import ClientStats, Inbound
|
|
13
|
+
from .api_core import ProductionInboundCache, SessionCore, TgIDClientService, IdentityResolver
|
|
14
|
+
from .api_core.session_core import (
|
|
15
|
+
CookieType,
|
|
16
|
+
DataType,
|
|
17
|
+
HeaderType,
|
|
18
|
+
ParamType,
|
|
19
|
+
)
|
|
20
|
+
from .util import JsonType
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class XUIClient:
|
|
24
|
+
"""Facade for the 3X-UI panel API.
|
|
25
|
+
|
|
26
|
+
XUIClient owns and wires up the components that do the real work:
|
|
27
|
+
the HTTP/auth core (``SessionCore``), the production inbound cache,
|
|
28
|
+
the identity resolver, the endpoint handlers, and the high-level
|
|
29
|
+
Telegram-ID client service. It also drives the lifecycle
|
|
30
|
+
(``connect`` / ``login`` / ``disconnect`` and the async context
|
|
31
|
+
manager protocol).
|
|
32
|
+
|
|
33
|
+
All the documented attributes below that map to session or auth state
|
|
34
|
+
are read-only passthroughs to the underlying ``SessionCore`` and remain
|
|
35
|
+
in place for backward compatibility.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
connected: Whether an HTTP session is currently open.
|
|
39
|
+
PROD_STRING: Compiled regex used to identify production inbounds
|
|
40
|
+
(alias of ``ProductionInboundCache.PROD_STRING`` on ``_prod_cache``).
|
|
41
|
+
session, base_host, base_port, base_path, base_url,
|
|
42
|
+
session_start, session_duration, xui_username, xui_password,
|
|
43
|
+
two_fac_secret, totp, max_retries, retry_delay:
|
|
44
|
+
Pass-through to ``SessionCore``.
|
|
45
|
+
sub_gen, uuid_gen: Pass-through to ``IdentityResolver``.
|
|
46
|
+
server_end, clients_end, inbounds_end: Endpoint handlers.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, base_website: str, base_port: int, base_path: str,
|
|
50
|
+
*, username: str | None = None, password: str | None = None,
|
|
51
|
+
two_fac_code: str | None = None, session_duration: int = 3600,
|
|
52
|
+
custom_prod_string: str = "testing",
|
|
53
|
+
max_retries: int = 5, retry_delay: int = 1,
|
|
54
|
+
custom_sub_generator: Callable[[int], str] | Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
|
|
55
|
+
custom_uuid_generator: Callable[[int], str] | Callable[[int], Awaitable[str]] = util.get_uuid_from_tgid,
|
|
56
|
+
panel_id: Any = None
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Initialize the XUIClient.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
base_website: The server hostname (e.g., "example.com").
|
|
62
|
+
base_port: The server port (e.g., 443).
|
|
63
|
+
base_path: The base path for the API (e.g., "/panel").
|
|
64
|
+
username: Username for authentication.
|
|
65
|
+
password: Password for authentication.
|
|
66
|
+
two_fac_code: TOTP secret for 2FA. Short one-shot codes are
|
|
67
|
+
accepted for the current login only.
|
|
68
|
+
session_duration: Maximum session duration in seconds. Defaults to 3600.
|
|
69
|
+
custom_prod_string: Regex pattern used to select production inbounds.
|
|
70
|
+
max_retries: Maximum retries for database-lock responses.
|
|
71
|
+
retry_delay: Seconds to wait between database-lock retries.
|
|
72
|
+
custom_sub_generator: Sync or async callable that receives a
|
|
73
|
+
Telegram ID and returns the subscription ID for new clients.
|
|
74
|
+
custom_uuid_generator: Sync or async callable that receives a
|
|
75
|
+
Telegram ID and returns the UUID for new clients.
|
|
76
|
+
panel_id: this is solely for user's purposes to increase logging and accounting clarity. Default is None.
|
|
77
|
+
"""
|
|
78
|
+
self._core = SessionCore(
|
|
79
|
+
base_website,
|
|
80
|
+
base_port,
|
|
81
|
+
base_path,
|
|
82
|
+
username=username,
|
|
83
|
+
password=password,
|
|
84
|
+
two_fac_code=two_fac_code,
|
|
85
|
+
session_duration=session_duration,
|
|
86
|
+
max_retries=max_retries,
|
|
87
|
+
retry_delay=retry_delay,
|
|
88
|
+
panel_id=panel_id,
|
|
89
|
+
)
|
|
90
|
+
self._identity = IdentityResolver(custom_sub_generator, custom_uuid_generator)
|
|
91
|
+
self.sub_gen = self._identity.sub_gen
|
|
92
|
+
self.uuid_gen = self._identity.uuid_gen
|
|
93
|
+
self.panel_id: int | str | Any = panel_id
|
|
94
|
+
self.server_end = endpoints.Server(self._core)
|
|
95
|
+
self.clients_end = endpoints.Clients(self._core)
|
|
96
|
+
self.inbounds_end = endpoints.Inbounds(self._core)
|
|
97
|
+
self._prod_cache = ProductionInboundCache(
|
|
98
|
+
self.inbounds_end,
|
|
99
|
+
custom_prod_string,
|
|
100
|
+
panel_id=self.panel_id,
|
|
101
|
+
)
|
|
102
|
+
self.PROD_STRING = self._prod_cache.PROD_STRING
|
|
103
|
+
self.get_production_inbounds = self._prod_cache.get
|
|
104
|
+
self._tg_client_service = TgIDClientService(
|
|
105
|
+
self.clients_end,
|
|
106
|
+
self.inbounds_end,
|
|
107
|
+
self._identity,
|
|
108
|
+
self._prod_cache,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def session(self) -> AsyncClient | None:
|
|
113
|
+
return self._core.session
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def session_start(self) -> float | None:
|
|
117
|
+
return self._core.session_start
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def session_duration(self) -> int:
|
|
121
|
+
return self._core.session_duration
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def base_host(self) -> str:
|
|
125
|
+
return self._core.base_host
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def base_port(self) -> int:
|
|
129
|
+
return self._core.base_port
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def base_path(self) -> str:
|
|
133
|
+
return self._core.base_path
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def base_url(self) -> str:
|
|
137
|
+
return self._core.base_url
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def xui_username(self) -> str | None:
|
|
141
|
+
return self._core.xui_username
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def xui_password(self) -> str | None:
|
|
145
|
+
return self._core.xui_password
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def two_fac_secret(self) -> SecretStr | None:
|
|
149
|
+
return self._core.two_fac_secret
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def totp(self) -> pyotp.TOTP | None:
|
|
153
|
+
return self._core.totp
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def max_retries(self) -> int:
|
|
157
|
+
return self._core.max_retries
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def retry_delay(self) -> int:
|
|
161
|
+
return self._core.retry_delay
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def connected(self) -> bool:
|
|
165
|
+
return self._core.connected
|
|
166
|
+
|
|
167
|
+
@overload
|
|
168
|
+
async def _safe_request(self, *, request_to_send: httpx.Request) -> Response:
|
|
169
|
+
...
|
|
170
|
+
|
|
171
|
+
@overload
|
|
172
|
+
async def _safe_request(self,
|
|
173
|
+
method: Literal["get", "post", "patch", "delete", "put"],
|
|
174
|
+
**kwargs: Any,
|
|
175
|
+
) -> Response:
|
|
176
|
+
...
|
|
177
|
+
|
|
178
|
+
async def _safe_request(self,
|
|
179
|
+
method: Literal["get", "post", "patch", "delete", "put"] | None = None,
|
|
180
|
+
**kwargs: Any,
|
|
181
|
+
) -> Response:
|
|
182
|
+
"""Delegate for :meth:`BaseModel.from_response` DB-lock retries."""
|
|
183
|
+
return await self._core._safe_request(method=method, **kwargs)
|
|
184
|
+
|
|
185
|
+
async def safe_get(self,
|
|
186
|
+
url: httpx.URL | str,
|
|
187
|
+
*,
|
|
188
|
+
params: ParamType | None = None,
|
|
189
|
+
headers: HeaderType | None = None,
|
|
190
|
+
cookies: CookieType | None = None,
|
|
191
|
+
) -> Response:
|
|
192
|
+
"""Execute a safe GET request with automatic retry on database lock.
|
|
193
|
+
|
|
194
|
+
Note:
|
|
195
|
+
"Safe" only means "with retries if database is locked".
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
url: The URL to request.
|
|
199
|
+
params: Query parameters (optional).
|
|
200
|
+
headers: Request headers (optional).
|
|
201
|
+
cookies: Request cookies (optional).
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The HTTP response.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
RuntimeError: If the session is not initialized.
|
|
208
|
+
"""
|
|
209
|
+
return await self._core.safe_get(
|
|
210
|
+
url, params=params, headers=headers, cookies=cookies
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async def safe_post(self,
|
|
214
|
+
url: httpx.URL | str,
|
|
215
|
+
*,
|
|
216
|
+
content: DataType | None = None,
|
|
217
|
+
data: JsonType | None = None,
|
|
218
|
+
json: Any | None = None,
|
|
219
|
+
params: ParamType | None = None,
|
|
220
|
+
headers: HeaderType | None = None,
|
|
221
|
+
cookies: CookieType | None = None,
|
|
222
|
+
) -> Response:
|
|
223
|
+
"""Execute a safe POST request with automatic retry on database lock.
|
|
224
|
+
|
|
225
|
+
Note:
|
|
226
|
+
"Safe" only means "with retries if database is locked".
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
url: The URL to request.
|
|
230
|
+
content: Request content (optional).
|
|
231
|
+
data: Form data (optional).
|
|
232
|
+
json: JSON body (optional).
|
|
233
|
+
params: Query parameters (optional).
|
|
234
|
+
headers: Request headers (optional).
|
|
235
|
+
cookies: Request cookies (optional).
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The HTTP response.
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
RuntimeError: If the session is not initialized.
|
|
242
|
+
"""
|
|
243
|
+
return await self._core.safe_post(
|
|
244
|
+
url,
|
|
245
|
+
content=content,
|
|
246
|
+
data=data,
|
|
247
|
+
json=json,
|
|
248
|
+
params=params,
|
|
249
|
+
headers=headers,
|
|
250
|
+
cookies=cookies,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
async def login(self) -> None:
|
|
254
|
+
"""Authenticate the client with the 3X-UI panel.
|
|
255
|
+
|
|
256
|
+
This method performs the login action, establishing a session for
|
|
257
|
+
subsequent API requests.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ValueError: If the login credentials are incorrect.
|
|
261
|
+
RuntimeError: If the server returns an error status code.
|
|
262
|
+
"""
|
|
263
|
+
await self._core.login()
|
|
264
|
+
|
|
265
|
+
def connect(self) -> Self:
|
|
266
|
+
"""Establish a connection to the 3X-UI panel.
|
|
267
|
+
|
|
268
|
+
This method creates an async HTTP client session.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Self: The XUIClient instance.
|
|
272
|
+
"""
|
|
273
|
+
self._core.connect()
|
|
274
|
+
return self
|
|
275
|
+
|
|
276
|
+
async def disconnect(self) -> None:
|
|
277
|
+
"""Close the client session.
|
|
278
|
+
|
|
279
|
+
This method closes the async HTTP client session.
|
|
280
|
+
"""
|
|
281
|
+
await self._prod_cache.stop()
|
|
282
|
+
await self._core.disconnect()
|
|
283
|
+
|
|
284
|
+
async def __aenter__(self) -> Self:
|
|
285
|
+
"""Enter the async context manager.
|
|
286
|
+
|
|
287
|
+
This method is called when the client is used in an `async with`
|
|
288
|
+
statement. It establishes a connection and starts the cache clearing
|
|
289
|
+
task.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Self: The XUIClient instance.
|
|
293
|
+
"""
|
|
294
|
+
self.connect()
|
|
295
|
+
await self.login()
|
|
296
|
+
self._prod_cache.start()
|
|
297
|
+
return self
|
|
298
|
+
|
|
299
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
300
|
+
"""Exit the async context manager.
|
|
301
|
+
|
|
302
|
+
This method is called when the client context is exited. It closes
|
|
303
|
+
the client session.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
exc_type: The exception type, if an exception occurred.
|
|
307
|
+
exc_val: The exception value, if an exception occurred.
|
|
308
|
+
exc_tb: The exception traceback, if an exception occurred.
|
|
309
|
+
"""
|
|
310
|
+
if exc_type is None or exc_type is asyncio.exceptions.CancelledError:
|
|
311
|
+
logging.info("Client is disconnecting (panel: %s)", self.panel_id or self.base_host)
|
|
312
|
+
else:
|
|
313
|
+
logging.warning("Client is disconnecting due to an error (may be unrelated):"
|
|
314
|
+
"\n%s, with value %s\nStacktrace:%s",
|
|
315
|
+
exc_type, exc_val, exc_tb, exc_info=exc_tb)
|
|
316
|
+
logging.info("Client is disconnecting: %s", self.panel_id or self.base_host)
|
|
317
|
+
await self.disconnect()
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
#=========================="meta" methods==========================
|
|
321
|
+
async def _resolve_uuid(self, telegram_id: int) -> str:
|
|
322
|
+
"""Resolve a Telegram ID to a UUID via ``self.uuid_gen``.
|
|
323
|
+
|
|
324
|
+
Handles both sync and async callables.
|
|
325
|
+
"""
|
|
326
|
+
return await self._identity.resolve_uuid(telegram_id)
|
|
327
|
+
|
|
328
|
+
async def _resolve_sub(self, telegram_id: int) -> str:
|
|
329
|
+
"""Resolve the subscription ID from a telegram id via ``self.sub_gen``
|
|
330
|
+
|
|
331
|
+
Handles both sync and async callables.
|
|
332
|
+
"""
|
|
333
|
+
return await self._identity.resolve_sub(telegram_id)
|
|
334
|
+
|
|
335
|
+
#========================inbound management========================
|
|
336
|
+
async def add_inbound(self, inbound: Inbound) -> Response:
|
|
337
|
+
"""Create a new inbound. Returns the panel HTTP response."""
|
|
338
|
+
return await self.inbounds_end.add_inbound(inbound)
|
|
339
|
+
|
|
340
|
+
async def delete_inbound(self, inbound_id: int) -> Response:
|
|
341
|
+
"""Delete an inbound by panel ID. Returns the panel HTTP response."""
|
|
342
|
+
return await self.inbounds_end.delete_inbound_by_id(inbound_id)
|
|
343
|
+
|
|
344
|
+
#========================clients management========================
|
|
345
|
+
async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> list[ClientStats]:
|
|
346
|
+
"""Retrieve client information by Telegram ID.
|
|
347
|
+
|
|
348
|
+
This method fetches client information using the Telegram ID. If
|
|
349
|
+
an inbound ID is provided, it fetches the client by email derived
|
|
350
|
+
from the Telegram ID and inbound ID.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
tgid: The Telegram ID of the client.
|
|
354
|
+
inbound_id: The ID of the inbound (optional).
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
List[ClientStats]: A list of client statistics.
|
|
358
|
+
|
|
359
|
+
Note:
|
|
360
|
+
If the client is not found by Telegram ID, the method falls back
|
|
361
|
+
to using the Telegram ID and inbound ID to fetch the client.
|
|
362
|
+
"""
|
|
363
|
+
return await self._tg_client_service.get_client_with_tgid(tgid, inbound_id)
|
|
364
|
+
|
|
365
|
+
async def create_and_add_prod_client(self, telegram_id: int, *,
|
|
366
|
+
additional_remark: str | None = None,
|
|
367
|
+
expiry_time: int = 0,
|
|
368
|
+
exist_ok: bool = False,
|
|
369
|
+
replace_if_exist: bool = False,
|
|
370
|
+
) -> dict[int, Response]:
|
|
371
|
+
"""Create and add a production client.
|
|
372
|
+
|
|
373
|
+
This method creates a new client with the given Telegram ID and
|
|
374
|
+
adds it to the production inbounds. The client is configured with
|
|
375
|
+
default settings and the additional remark. The subscription ID is
|
|
376
|
+
created by ``self.sub_gen``; by default this is
|
|
377
|
+
``util.default_sub_from_tgid``.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
telegram_id: The Telegram ID of the client.
|
|
381
|
+
additional_remark: An optional additional remark for the client.
|
|
382
|
+
expiry_time: Expiry time in SECONDS as a UNIX timestamp.
|
|
383
|
+
exist_ok: If True, return API responses even when the panel reports
|
|
384
|
+
a duplicate email.
|
|
385
|
+
replace_if_exist: If True, inbounds that respond with
|
|
386
|
+
"duplicate email" will have their existing client updated
|
|
387
|
+
(via ``request_update_client``) instead of raising an error.
|
|
388
|
+
Updates are burst-shot in a second ``asyncio.gather`` for
|
|
389
|
+
minimal latency.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Dict[int, Response]: A mapping of inbound IDs to API responses.
|
|
393
|
+
For inbounds where the add succeeded, the response is from the
|
|
394
|
+
add call. For inbounds where ``replace_if_exist`` replaced a
|
|
395
|
+
duplicate, the response is from the update call.
|
|
396
|
+
|
|
397
|
+
Raises:
|
|
398
|
+
ClientEmailAlreadyExistsError: If a duplicate client is reported,
|
|
399
|
+
``replace_if_exist`` is False, and ``exist_ok`` is False.
|
|
400
|
+
"""
|
|
401
|
+
return await self._tg_client_service.create_and_add_prod_client(
|
|
402
|
+
telegram_id,
|
|
403
|
+
additional_remark=additional_remark,
|
|
404
|
+
expiry_time=expiry_time,
|
|
405
|
+
exist_ok=exist_ok,
|
|
406
|
+
replace_if_exist=replace_if_exist,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
async def update_client_by_tgid_only(self, telegram_id: int, prod_only: bool, /, *,
|
|
410
|
+
security: str | None = None,
|
|
411
|
+
password: str | None = None,
|
|
412
|
+
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
|
|
413
|
+
limit_ip: int | None = None,
|
|
414
|
+
limit_gb: int | None = None,
|
|
415
|
+
expiry_time: int | None = None,
|
|
416
|
+
enable: bool | None = None,
|
|
417
|
+
sub_id: str | None = None,
|
|
418
|
+
comment: str | None = None,
|
|
419
|
+
verbose: bool = True
|
|
420
|
+
) -> list[Response]:
|
|
421
|
+
"""Update every matching client found by Telegram ID.
|
|
422
|
+
|
|
423
|
+
The client UUID is derived from ``telegram_id`` and searched across
|
|
424
|
+
either production inbounds or all inbounds. Only keyword arguments with
|
|
425
|
+
non-None values are applied to the client model before sending update
|
|
426
|
+
requests.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
telegram_id: Telegram ID used to derive the client UUID.
|
|
430
|
+
prod_only: If True, search only production inbounds. If False,
|
|
431
|
+
search every inbound returned by the panel.
|
|
432
|
+
security: New security setting.
|
|
433
|
+
password: New password.
|
|
434
|
+
flow: New VLESS flow value.
|
|
435
|
+
limit_ip: New simultaneous IP connection limit.
|
|
436
|
+
limit_gb: New traffic limit in gigabytes.
|
|
437
|
+
expiry_time: New expiry timestamp in seconds.
|
|
438
|
+
enable: New enabled state.
|
|
439
|
+
sub_id: New subscription ID.
|
|
440
|
+
comment: New client comment.
|
|
441
|
+
verbose: If True, warn when ``expiry_time`` looks like a duration
|
|
442
|
+
instead of a UNIX timestamp.
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
Responses from each inbound where a matching client was updated.
|
|
446
|
+
"""
|
|
447
|
+
return await self._tg_client_service.update_client_by_tgid_only(
|
|
448
|
+
telegram_id,
|
|
449
|
+
prod_only,
|
|
450
|
+
security=security,
|
|
451
|
+
password=password,
|
|
452
|
+
flow=flow,
|
|
453
|
+
limit_ip=limit_ip,
|
|
454
|
+
limit_gb=limit_gb,
|
|
455
|
+
expiry_time=expiry_time,
|
|
456
|
+
enable=enable,
|
|
457
|
+
sub_id=sub_id,
|
|
458
|
+
comment=comment,
|
|
459
|
+
verbose=verbose,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
async def update_client_by_tgid_inbid(self, telegram_id: int, inbound_id: int, /, *,
|
|
463
|
+
security: str | None = None,
|
|
464
|
+
password: str | None = None,
|
|
465
|
+
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
|
|
466
|
+
limit_ip: int | None = None,
|
|
467
|
+
limit_gb: int | None = None,
|
|
468
|
+
expiry_time: int | None = None,
|
|
469
|
+
enable: bool | None = None,
|
|
470
|
+
sub_id: str | None = None,
|
|
471
|
+
comment: str | None = None,
|
|
472
|
+
email: str | None = None,
|
|
473
|
+
verbose: bool = True) -> Response:
|
|
474
|
+
"""
|
|
475
|
+
Update a client in a specific inbound by Telegram ID. NOT optimized for multiple inbounds.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
telegram_id: The Telegram ID of the client
|
|
479
|
+
inbound_id: The ID of the inbound where the client exists
|
|
480
|
+
security: Client security setting (optional)
|
|
481
|
+
password: Client password (optional)
|
|
482
|
+
flow: VLESS flow type (optional)
|
|
483
|
+
limit_ip: IP connection limit (optional)
|
|
484
|
+
limit_gb: Data limit in GB (optional)
|
|
485
|
+
expiry_time: Client expiry time (UNIX timestamp) (optional)
|
|
486
|
+
enable: Whether the client is enabled (optional)
|
|
487
|
+
sub_id: Subscription ID (optional)
|
|
488
|
+
comment: Client comment/note (optional)
|
|
489
|
+
email: New client email (optional). USE WITH CAUTION BECAUSE THE PANEL WILL NOT TRACK THE NEW EMAIL.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Response from the API
|
|
493
|
+
"""
|
|
494
|
+
return await self._tg_client_service.update_client_by_tgid_inbid(
|
|
495
|
+
telegram_id,
|
|
496
|
+
inbound_id,
|
|
497
|
+
security=security,
|
|
498
|
+
password=password,
|
|
499
|
+
flow=flow,
|
|
500
|
+
limit_ip=limit_ip,
|
|
501
|
+
limit_gb=limit_gb,
|
|
502
|
+
expiry_time=expiry_time,
|
|
503
|
+
enable=enable,
|
|
504
|
+
sub_id=sub_id,
|
|
505
|
+
comment=comment,
|
|
506
|
+
email=email,
|
|
507
|
+
verbose=verbose,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
async def delete_client_by_tgid(self, telegram_id: int, inbound_id: int) -> Response:
|
|
511
|
+
"""Delete a client from a specific inbound by Telegram ID.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
telegram_id: The Telegram ID of the client
|
|
515
|
+
inbound_id: The ID of the inbound
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
Response from the API
|
|
519
|
+
"""
|
|
520
|
+
return await self._tg_client_service.delete_client_by_tgid(telegram_id, inbound_id)
|
|
521
|
+
|
|
522
|
+
async def revoke_client_by_tgid_all_inbounds(self, telegram_id: int) -> List[Response]:
|
|
523
|
+
"""Delete a client from all production inbounds by Telegram ID.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
telegram_id: The Telegram ID of the client
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
List of Response objects from each deletion attempt
|
|
530
|
+
"""
|
|
531
|
+
return await self._tg_client_service.revoke_client_by_tgid_all_inbounds(telegram_id)
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
class ClientEmailAlreadyExistsError(Exception):
|
|
3
2
|
"""Raised when the panel rejects a new client because its email exists."""
|
|
4
3
|
|
|
@@ -8,6 +7,7 @@ class ClientEmailAlreadyExistsError(Exception):
|
|
|
8
7
|
else:
|
|
9
8
|
super().__init__(*args)
|
|
10
9
|
|
|
10
|
+
|
|
11
11
|
class EmailNotExistsError(Exception):
|
|
12
12
|
"""Raised when a requested client email cannot be found on the panel."""
|
|
13
13
|
|
|
@@ -17,6 +17,7 @@ class EmailNotExistsError(Exception):
|
|
|
17
17
|
else:
|
|
18
18
|
super().__init__(*args)
|
|
19
19
|
|
|
20
|
+
|
|
20
21
|
class ClientDoesNotExistError(Exception):
|
|
21
22
|
"""Raised when a requested client UUID is absent from the target inbound."""
|
|
22
23
|
|