Python-3xui 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_3xui/__init__.py +5 -0
- python_3xui/api.py +492 -0
- python_3xui/base_model.py +93 -0
- python_3xui/endpoints.py +361 -0
- python_3xui/models.py +277 -0
- python_3xui/util.py +265 -0
- python_3xui-0.0.1.dist-info/METADATA +38 -0
- python_3xui-0.0.1.dist-info/RECORD +10 -0
- python_3xui-0.0.1.dist-info/WHEEL +4 -0
- python_3xui-0.0.1.dist-info/licenses/LICENSE +201 -0
python_3xui/__init__.py
ADDED
python_3xui/api.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
from collections.abc import Sequence, Mapping
|
|
2
|
+
from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal
|
|
3
|
+
from datetime import datetime, UTC
|
|
4
|
+
|
|
5
|
+
from httpx import Response, AsyncClient
|
|
6
|
+
from async_lru import alru_cache
|
|
7
|
+
import asyncio
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from . import util
|
|
11
|
+
from .models import Inbound, SingleInboundClient, ClientStats
|
|
12
|
+
from .util import JsonType, async_range
|
|
13
|
+
|
|
14
|
+
DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
|
|
15
|
+
PrimitiveData = Optional[Union[str, int, float, bool]]
|
|
16
|
+
ParamType = Union[
|
|
17
|
+
Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]],
|
|
18
|
+
List[Tuple[str, PrimitiveData]],
|
|
19
|
+
Tuple[Tuple[str, PrimitiveData], ...],
|
|
20
|
+
str,
|
|
21
|
+
bytes,
|
|
22
|
+
]
|
|
23
|
+
CookieType = Union[Dict[str, str], List[Tuple[str, str]]]
|
|
24
|
+
HeaderType = Union[
|
|
25
|
+
Mapping[str, str],
|
|
26
|
+
Mapping[bytes, bytes],
|
|
27
|
+
Sequence[Tuple[str, str]],
|
|
28
|
+
Sequence[Tuple[bytes, bytes]],
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class XUIClient:
|
|
33
|
+
"""Main client for interacting with the 3X-UI panel API.
|
|
34
|
+
|
|
35
|
+
This class provides methods for authenticating with the 3X-UI panel,
|
|
36
|
+
managing sessions, and performing operations on inbounds and clients.
|
|
37
|
+
|
|
38
|
+
The client implements a singleton pattern to ensure only one instance
|
|
39
|
+
exists at a time.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
PROD_STRING: String used to identify production inbounds.
|
|
43
|
+
session: The async HTTP client session.
|
|
44
|
+
base_host: The server hostname.
|
|
45
|
+
base_port: The server port.
|
|
46
|
+
base_path: The base path for the API.
|
|
47
|
+
base_url: The full base URL for API requests.
|
|
48
|
+
session_start: Timestamp of when the session was created.
|
|
49
|
+
session_duration: Maximum session duration in seconds.
|
|
50
|
+
xui_username: Username for authentication.
|
|
51
|
+
xui_password: Password for authentication.
|
|
52
|
+
two_fac_code: Two-factor authentication code (if enabled).
|
|
53
|
+
max_retries: Maximum number of retry attempts for failed requests.
|
|
54
|
+
retry_delay: Delay in seconds between retries.
|
|
55
|
+
server_end: Server endpoint handler.
|
|
56
|
+
clients_end: Clients endpoint handler.
|
|
57
|
+
inbounds_end: Inbounds endpoint handler.
|
|
58
|
+
"""
|
|
59
|
+
_instance = None
|
|
60
|
+
|
|
61
|
+
def __init__(self, base_website: str, base_port: int, base_path: str,
|
|
62
|
+
*, xui_username: str | None = None, xui_password: str | None = None,
|
|
63
|
+
two_fac_code: str | None = None, session_duration: int = 3600) -> None:
|
|
64
|
+
"""Initialize the XUIClient.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
base_website: The server hostname (e.g., "example.com").
|
|
68
|
+
base_port: The server port (e.g., 443).
|
|
69
|
+
base_path: The base path for the API (e.g., "/panel").
|
|
70
|
+
xui_username: Username for authentication.
|
|
71
|
+
xui_password: Password for authentication.
|
|
72
|
+
two_fac_code: Two-factor authentication code (if enabled).
|
|
73
|
+
session_duration: Maximum session duration in seconds. Defaults to 3600.
|
|
74
|
+
"""
|
|
75
|
+
from . import endpoints # look, I know it's bad, but we need to evade cyclical imports
|
|
76
|
+
self.PROD_STRING = "tester-777"
|
|
77
|
+
self.session: AsyncClient | None = None
|
|
78
|
+
self.base_host: str = base_website
|
|
79
|
+
self.base_port: int = base_port
|
|
80
|
+
self.base_path: str = base_path
|
|
81
|
+
self.base_url: str = f"https://{self.base_host}:{self.base_port}{self.base_path}"
|
|
82
|
+
self.session_start: float | None = None
|
|
83
|
+
self.session_duration: int = session_duration
|
|
84
|
+
self.xui_username: str | None = xui_username
|
|
85
|
+
self.xui_password: str | None = xui_password
|
|
86
|
+
self.two_fac_code: str | None = two_fac_code
|
|
87
|
+
self.max_retries: int = 5
|
|
88
|
+
self.retry_delay: int = 1
|
|
89
|
+
# endpoints
|
|
90
|
+
self.server_end = endpoints.Server(self)
|
|
91
|
+
self.clients_end = endpoints.Clients(self)
|
|
92
|
+
self.inbounds_end = endpoints.Inbounds(self)
|
|
93
|
+
|
|
94
|
+
#========================singleton pattern========================
|
|
95
|
+
def __new__(cls, *args, **kwargs):
|
|
96
|
+
"""Create or return the singleton instance.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
*args: Positional arguments passed to __init__.
|
|
100
|
+
**kwargs: Keyword arguments passed to __init__.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The singleton XUIClient instance.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
if cls._instance is None:
|
|
107
|
+
|
|
108
|
+
cls._instance = super(XUIClient, cls).__new__(cls)
|
|
109
|
+
return cls._instance
|
|
110
|
+
|
|
111
|
+
#========================request stuffs========================
|
|
112
|
+
async def _safe_request(self,
|
|
113
|
+
method: Literal["get", "post", "patch", "delete", "put"],
|
|
114
|
+
**kwargs) -> Response:
|
|
115
|
+
"""Execute an HTTP request with automatic retry on database lock.
|
|
116
|
+
|
|
117
|
+
This method handles automatic session refresh and retries when
|
|
118
|
+
the 3X-UI database is locked.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
method: The HTTP method to use.
|
|
122
|
+
**kwargs: Additional arguments passed to the HTTP request.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The HTTP response.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
RuntimeError: If max retries exceeded or session is invalid.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async for attempt in async_range(self.max_retries):
|
|
133
|
+
resp = await self.session.request(method=method, **kwargs)
|
|
134
|
+
if resp.status_code // 100 != 2: #because it can return either 201 or 202
|
|
135
|
+
if resp.status_code == 404:
|
|
136
|
+
now: float = datetime.now(UTC).timestamp()
|
|
137
|
+
if self.session_start is None or now - self.session_start > self.session_duration:
|
|
138
|
+
|
|
139
|
+
await self.login()
|
|
140
|
+
continue
|
|
141
|
+
else:
|
|
142
|
+
raise RuntimeError("""Server returned a 404, and the session should still be valid, likely it's a REAL 404""")
|
|
143
|
+
else:
|
|
144
|
+
raise RuntimeError(f"Wrong status code: {resp.status_code}")
|
|
145
|
+
|
|
146
|
+
status = await util.check_xui_response_validity(resp)
|
|
147
|
+
if status == "OK":
|
|
148
|
+
return resp
|
|
149
|
+
elif status == "DB_LOCKED":
|
|
150
|
+
if attempt + 1 >= self.max_retries:
|
|
151
|
+
# resp.status_code = 518 # so the error can simply be handled as a "bad request"
|
|
152
|
+
# return resp
|
|
153
|
+
raise RuntimeError("Too many retries")
|
|
154
|
+
await asyncio.sleep(self.retry_delay)
|
|
155
|
+
continue
|
|
156
|
+
else:
|
|
157
|
+
return resp
|
|
158
|
+
raise RuntimeError(f"For some reason safe_request didn't exit, dump:\nmethod:\n{method}\n{kwargs}")
|
|
159
|
+
|
|
160
|
+
async def safe_get(self,
|
|
161
|
+
url: httpx.URL | str,
|
|
162
|
+
*,
|
|
163
|
+
params: ParamType | None = None,
|
|
164
|
+
headers: HeaderType | None = None,
|
|
165
|
+
cookies: CookieType | None = None) -> Response:
|
|
166
|
+
"""Execute a safe GET request with automatic retry on database lock.
|
|
167
|
+
|
|
168
|
+
Note:
|
|
169
|
+
"Safe" only means "with retries if database is locked".
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
url: The URL to request.
|
|
173
|
+
params: Query parameters (optional).
|
|
174
|
+
headers: Request headers (optional).
|
|
175
|
+
cookies: Request cookies (optional).
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The HTTP response.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
RuntimeError: If the session is not initialized.
|
|
182
|
+
"""
|
|
183
|
+
#NOTE: "safe" only means "with retries if database is locked"!
|
|
184
|
+
if self.session is None:
|
|
185
|
+
raise RuntimeError("Session is not initialized")
|
|
186
|
+
|
|
187
|
+
resp = await self._safe_request(method="get",
|
|
188
|
+
url=url,
|
|
189
|
+
params=params,
|
|
190
|
+
headers=headers,
|
|
191
|
+
cookies=cookies)
|
|
192
|
+
|
|
193
|
+
return resp
|
|
194
|
+
|
|
195
|
+
async def safe_post(self,
|
|
196
|
+
url: httpx.URL | str,
|
|
197
|
+
*,
|
|
198
|
+
content: DataType | None = None,
|
|
199
|
+
data: JsonType | None = None,
|
|
200
|
+
json: Any | None = None,
|
|
201
|
+
params: ParamType | None = None,
|
|
202
|
+
headers: HeaderType | None = None,
|
|
203
|
+
cookies: CookieType | None = None) -> Response:
|
|
204
|
+
"""Execute a safe POST request with automatic retry on database lock.
|
|
205
|
+
|
|
206
|
+
Note:
|
|
207
|
+
"Safe" only means "with retries if database is locked".
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
url: The URL to request.
|
|
211
|
+
content: Request content (optional).
|
|
212
|
+
data: Form data (optional).
|
|
213
|
+
json: JSON body (optional).
|
|
214
|
+
params: Query parameters (optional).
|
|
215
|
+
headers: Request headers (optional).
|
|
216
|
+
cookies: Request cookies (optional).
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
The HTTP response.
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
RuntimeError: If the session is not initialized.
|
|
223
|
+
"""
|
|
224
|
+
if self.session is None:
|
|
225
|
+
raise RuntimeError("Session is not initialized")
|
|
226
|
+
|
|
227
|
+
resp = await self._safe_request(method="post",
|
|
228
|
+
url=url,
|
|
229
|
+
content=content,
|
|
230
|
+
data=data,
|
|
231
|
+
json=json,
|
|
232
|
+
params=params,
|
|
233
|
+
headers=headers,
|
|
234
|
+
cookies=cookies)
|
|
235
|
+
return resp
|
|
236
|
+
|
|
237
|
+
#========================Login and session management==============================
|
|
238
|
+
async def login(self) -> None:
|
|
239
|
+
"""Authenticate the client with the 3X-UI panel.
|
|
240
|
+
|
|
241
|
+
This method performs the login action, establishing a session for
|
|
242
|
+
subsequent API requests.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
ValueError: If the login credentials are incorrect.
|
|
246
|
+
RuntimeError: If the server returns an error status code.
|
|
247
|
+
"""
|
|
248
|
+
payload = {
|
|
249
|
+
"username": self.xui_username,
|
|
250
|
+
"password": self.xui_password,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
resp = await self.session.post("/login", data=payload)
|
|
256
|
+
if resp.status_code == 200:
|
|
257
|
+
resp_json = resp.json()
|
|
258
|
+
if resp_json["success"]:
|
|
259
|
+
self.session_start: float = (datetime.now(UTC).timestamp())
|
|
260
|
+
return
|
|
261
|
+
else:
|
|
262
|
+
raise ValueError("Error: wrong credentials or failed login")
|
|
263
|
+
else:
|
|
264
|
+
raise RuntimeError(f"Error: server returned a status code of {resp.status_code}")
|
|
265
|
+
|
|
266
|
+
def connect(self) -> Self:
|
|
267
|
+
"""Establish a connection to the 3X-UI panel.
|
|
268
|
+
|
|
269
|
+
This method creates an async HTTP client session.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Self: The XUIClient instance.
|
|
273
|
+
"""
|
|
274
|
+
self.session = AsyncClient(base_url=self.base_url)
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
async def disconnect(self) -> None:
|
|
278
|
+
"""Close the client session.
|
|
279
|
+
|
|
280
|
+
This method closes the async HTTP client session.
|
|
281
|
+
"""
|
|
282
|
+
await self.session.aclose()
|
|
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
|
+
asyncio.create_task(self.clear_prod_inbound_cache())
|
|
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
|
+
|
|
311
|
+
await self.disconnect()
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
#========================inbound management========================
|
|
315
|
+
@alru_cache
|
|
316
|
+
async def get_production_inbounds(self) -> List[Inbound]:
|
|
317
|
+
"""Retrieve production inbounds.
|
|
318
|
+
|
|
319
|
+
This method fetches all inbounds and filters them based on the
|
|
320
|
+
production string. It is cached for efficiency.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
List[Inbound]: A list of production inbounds.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
RuntimeError: If no production inbounds are found.
|
|
327
|
+
"""
|
|
328
|
+
inbounds = await self.inbounds_end.get_all()
|
|
329
|
+
usable_inbounds: list[Inbound] = []
|
|
330
|
+
for inb in inbounds:
|
|
331
|
+
if self.PROD_STRING.lower() in inb.remark.lower():
|
|
332
|
+
usable_inbounds.append(inb)
|
|
333
|
+
if len(usable_inbounds) == 0:
|
|
334
|
+
raise RuntimeError("No production inbounds found! Change prod_string!")
|
|
335
|
+
|
|
336
|
+
return usable_inbounds
|
|
337
|
+
|
|
338
|
+
async def clear_prod_inbound_cache(self):
|
|
339
|
+
"""Clear the production inbound cache.
|
|
340
|
+
|
|
341
|
+
This method clears the cache of production inbounds and refills it
|
|
342
|
+
by fetching the inbounds again. It is intended to be run as a
|
|
343
|
+
background task.
|
|
344
|
+
|
|
345
|
+
Note:
|
|
346
|
+
This method currently runs every 10 seconds. Please change the
|
|
347
|
+
timer from 5 to 60*60*24 in the code.
|
|
348
|
+
"""
|
|
349
|
+
while True:
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
self.get_production_inbounds.cache_clear()
|
|
353
|
+
await self.get_production_inbounds() #fill the cache
|
|
354
|
+
await asyncio.sleep(3600) #every hour
|
|
355
|
+
|
|
356
|
+
#========================clients management========================
|
|
357
|
+
async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> List[ClientStats]:
|
|
358
|
+
"""Retrieve client information by Telegram ID.
|
|
359
|
+
|
|
360
|
+
This method fetches client information using the Telegram ID. If
|
|
361
|
+
an inbound ID is provided, it fetches the client by email derived
|
|
362
|
+
from the Telegram ID and inbound ID.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
tgid: The Telegram ID of the client.
|
|
366
|
+
inbound_id: The ID of the inbound (optional).
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
List[ClientStats]: A list of client statistics.
|
|
370
|
+
|
|
371
|
+
Note:
|
|
372
|
+
If the client is not found by Telegram ID, the method falls back
|
|
373
|
+
to using the Telegram ID and inbound ID to fetch the client.
|
|
374
|
+
"""
|
|
375
|
+
if inbound_id:
|
|
376
|
+
email = util.generate_email_from_tgid_inbid(tgid, inbound_id)
|
|
377
|
+
resp = [await self.clients_end.get_client_with_email(email)]
|
|
378
|
+
return resp
|
|
379
|
+
uuid = util.get_telegram_uuid(tgid)
|
|
380
|
+
resp = await self.clients_end.get_client_with_uuid(uuid)
|
|
381
|
+
return resp
|
|
382
|
+
|
|
383
|
+
async def create_and_add_prod_client(self, telegram_id: int, additional_remark: str = None):
|
|
384
|
+
"""Create and add a production client.
|
|
385
|
+
|
|
386
|
+
This method creates a new client with the given Telegram ID and
|
|
387
|
+
adds it to the production inbounds. The client is configured with
|
|
388
|
+
default settings and the additional remark.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
telegram_id: The Telegram ID of the client.
|
|
392
|
+
additional_remark: An optional additional remark for the client.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
List[Response]: A list of responses from the server for each
|
|
396
|
+
inbound the client was added to.
|
|
397
|
+
"""
|
|
398
|
+
production_inbounds: List[Inbound] = await self.get_production_inbounds()
|
|
399
|
+
|
|
400
|
+
responses = []
|
|
401
|
+
for inb in production_inbounds:
|
|
402
|
+
client = SingleInboundClient.model_construct(
|
|
403
|
+
uuid=util.get_telegram_uuid(telegram_id),
|
|
404
|
+
flow="",
|
|
405
|
+
email=util.generate_email_from_tgid_inbid(telegram_id, inb.id),
|
|
406
|
+
limit_gb=0,
|
|
407
|
+
enable=True,
|
|
408
|
+
subscription_id=util.sub_from_tgid(telegram_id),
|
|
409
|
+
comment=f"{additional_remark}, created at {datetime.now(UTC)}")
|
|
410
|
+
responses.append(await self.clients_end.add_client(client, inb.id))
|
|
411
|
+
return responses
|
|
412
|
+
|
|
413
|
+
async def update_client_by_tgid(self, telegram_id: int, inbound_id: int, /,
|
|
414
|
+
security: str | None = None,
|
|
415
|
+
password: str | None = None,
|
|
416
|
+
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
|
|
417
|
+
limit_ip: int | None = None,
|
|
418
|
+
limit_gb: int | None = None,
|
|
419
|
+
expiry_time: int | None = None,
|
|
420
|
+
enable: bool | None = None,
|
|
421
|
+
sub_id: str | None = None,
|
|
422
|
+
comment: str | None = None) -> Response:
|
|
423
|
+
"""
|
|
424
|
+
Update a client in a specific inbound by Telegram ID.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
telegram_id: The Telegram ID of the client
|
|
428
|
+
inbound_id: The ID of the inbound where the client exists
|
|
429
|
+
security: Client security setting
|
|
430
|
+
password: Client password
|
|
431
|
+
flow: VLESS flow type
|
|
432
|
+
limit_ip: IP connection limit
|
|
433
|
+
limit_gb: Data limit in GB
|
|
434
|
+
expiry_time: Client expiry time (UNIX timestamp)
|
|
435
|
+
enable: Whether the client is enabled
|
|
436
|
+
sub_id: Subscription ID
|
|
437
|
+
comment: Client comment/note
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Response from the API
|
|
441
|
+
"""
|
|
442
|
+
email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
|
|
443
|
+
existing_client = await self.clients_end.get_client_with_email(email)
|
|
444
|
+
|
|
445
|
+
resp = await self.clients_end.update_single_client(
|
|
446
|
+
SingleInboundClient.model_validate(existing_client.model_dump()),
|
|
447
|
+
inbound_id,
|
|
448
|
+
security=security,
|
|
449
|
+
password=password,
|
|
450
|
+
flow=flow,
|
|
451
|
+
limit_ip=limit_ip,
|
|
452
|
+
limit_gb=limit_gb,
|
|
453
|
+
expiry_time=expiry_time,
|
|
454
|
+
enable=enable,
|
|
455
|
+
sub_id=sub_id,
|
|
456
|
+
comment=comment
|
|
457
|
+
)
|
|
458
|
+
return resp
|
|
459
|
+
|
|
460
|
+
async def delete_client_by_tgid(self, telegram_id: int, inbound_id: int) -> Response:
|
|
461
|
+
"""Delete a client from a specific inbound by Telegram ID.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
telegram_id: The Telegram ID of the client
|
|
465
|
+
inbound_id: The ID of the inbound
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
Response from the API
|
|
469
|
+
"""
|
|
470
|
+
email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
|
|
471
|
+
resp = await self.clients_end.delete_client_by_email(email, inbound_id)
|
|
472
|
+
return resp
|
|
473
|
+
|
|
474
|
+
async def delete_client_by_tgid_all_inbounds(self, telegram_id: int) -> List[Response]:
|
|
475
|
+
"""Delete a client from all production inbounds by Telegram ID.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
telegram_id: The Telegram ID of the client
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
List of Response objects from each deletion attempt
|
|
482
|
+
"""
|
|
483
|
+
production_inbounds = await self.get_production_inbounds()
|
|
484
|
+
responses = []
|
|
485
|
+
|
|
486
|
+
for inbound in production_inbounds:
|
|
487
|
+
email = util.generate_email_from_tgid_inbid(telegram_id, inbound.id)
|
|
488
|
+
resp = await self.clients_end.delete_client_by_email(email, inbound.id)
|
|
489
|
+
responses.append(resp)
|
|
490
|
+
|
|
491
|
+
return responses
|
|
492
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, overload, Self, ClassVar, Annotated, Literal, Callable
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
import httpx
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
|
|
9
|
+
from . import models
|
|
10
|
+
from . import util
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from api import XUIClient
|
|
14
|
+
|
|
15
|
+
class BaseModel(pydantic.BaseModel):
|
|
16
|
+
"""Base model for all 3X-UI API data models.
|
|
17
|
+
|
|
18
|
+
Provides common functionality for parsing API responses and maintaining
|
|
19
|
+
references to the XUIClient instance.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
ERROR_RETRIES: Class variable for number of retry attempts on errors.
|
|
23
|
+
ERROR_RETRY_COOLDOWN: Class variable for cooldown between retries in seconds.
|
|
24
|
+
"""
|
|
25
|
+
ERROR_RETRIES: ClassVar[int] = 5
|
|
26
|
+
ERROR_RETRY_COOLDOWN: ClassVar[int] = 1
|
|
27
|
+
|
|
28
|
+
model_config = pydantic.ConfigDict(ignored_types=(cached_property, ))
|
|
29
|
+
|
|
30
|
+
def model_post_init(self, context: Any, /) -> None:
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_list(cls, args: List[Dict[str, Any]],
|
|
36
|
+
client: "XUIClient"
|
|
37
|
+
) -> List[Self]:
|
|
38
|
+
"""Create a list of model instances from a list of dictionaries.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
args: A list of dictionaries containing model data.
|
|
42
|
+
client: The XUIClient instance to associate with each model.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
A list of model instances initialized with the provided data.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
inbounds = Inbound.from_list([{"id": 1}, {"id": 2}], client=xui_client)
|
|
49
|
+
"""
|
|
50
|
+
return [cls(**obj) for obj in args]
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
async def from_response(
|
|
54
|
+
cls,
|
|
55
|
+
response: httpx.Response,
|
|
56
|
+
client: "XUIClient",
|
|
57
|
+
expect: list|dict,
|
|
58
|
+
auto_retry: bool = True
|
|
59
|
+
) -> Union[Self, List[Self]]:
|
|
60
|
+
"""Create model instance(s) from an HTTP response.
|
|
61
|
+
|
|
62
|
+
Parses the response JSON and creates model instance(s) based on the
|
|
63
|
+
expected type. Handles automatic retry for database lock errors.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
response: The httpx Response object from an API request.
|
|
67
|
+
client: The XUIClient instance to associate with the model(s).
|
|
68
|
+
expect: The expected type - either `list` or `dict`. Used to
|
|
69
|
+
determine whether to parse a single object or a list.
|
|
70
|
+
auto_retry: Whether to automatically retry on database lock errors.
|
|
71
|
+
Defaults to True.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Either a single model instance (if expect is dict) or a list of
|
|
75
|
+
model instances (if expect is list).
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If the response is invalid or the operation failed.
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
inbound = await Inbound.from_response(response, client, dict)
|
|
82
|
+
inbounds = await Inbound.from_response(response, client, list)
|
|
83
|
+
"""
|
|
84
|
+
json_resp: util.JsonType = response.json()
|
|
85
|
+
valid = util.check_xui_response_validity(json_resp)
|
|
86
|
+
if valid == "OK":
|
|
87
|
+
obj = json_resp["obj"]
|
|
88
|
+
if expect is list:
|
|
89
|
+
return cls.from_list(obj, client=client)
|
|
90
|
+
if expect is dict:
|
|
91
|
+
return cls(**obj, client=client)
|
|
92
|
+
else:
|
|
93
|
+
raise ValueError(f"Invalid 3X-UI response, code {valid}")
|