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.
@@ -0,0 +1,5 @@
1
+ from .api import XUIClient
2
+
3
+ __author__ = "JustMe_001"
4
+ __version__ = "0.0.1"
5
+ __email__ = ""
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}")