Python-3xui 0.0.8__py3-none-any.whl → 0.0.9__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 +4 -2
- python_3xui/api.py +106 -45
- python_3xui/base_model.py +9 -12
- python_3xui/custom_exceptions.py +12 -0
- python_3xui/endpoints.py +46 -48
- python_3xui/models.py +54 -29
- python_3xui/util.py +14 -14
- {python_3xui-0.0.8.dist-info → python_3xui-0.0.9.dist-info}/METADATA +13 -6
- python_3xui-0.0.9.dist-info/RECORD +11 -0
- python_3xui-0.0.8.dist-info/RECORD +0 -10
- {python_3xui-0.0.8.dist-info → python_3xui-0.0.9.dist-info}/WHEEL +0 -0
- {python_3xui-0.0.8.dist-info → python_3xui-0.0.9.dist-info}/licenses/LICENSE +0 -0
python_3xui/__init__.py
CHANGED
python_3xui/api.py
CHANGED
|
@@ -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,
|
python_3xui/base_model.py
CHANGED
|
@@ -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)
|
python_3xui/endpoints.py
CHANGED
|
@@ -7,9 +7,9 @@ from httpx import Response
|
|
|
7
7
|
from pydantic import ValidationError
|
|
8
8
|
from pydantic.main import ModelT
|
|
9
9
|
|
|
10
|
-
from . import models
|
|
11
10
|
from .api import XUIClient
|
|
12
|
-
from .
|
|
11
|
+
from .custom_exceptions import ClientDoesNotExistError
|
|
12
|
+
from .models import Inbound, SingleInboundClient, ClientStats, InboundClients, timestamp_seconds, ClientsSettings
|
|
13
13
|
from .util import JsonType
|
|
14
14
|
|
|
15
15
|
|
|
@@ -127,22 +127,22 @@ class Inbounds(BaseEndpoint):
|
|
|
127
127
|
A list of Inbound model instances.
|
|
128
128
|
"""
|
|
129
129
|
endpoint = "/list"
|
|
130
|
-
|
|
131
|
-
inbounds = Inbound.from_list(
|
|
130
|
+
json_resp = await self._simple_get(f"{endpoint}")
|
|
131
|
+
inbounds = Inbound.from_list(json_resp)
|
|
132
132
|
return inbounds
|
|
133
133
|
|
|
134
|
-
async def get_specific_inbound(self,
|
|
134
|
+
async def get_specific_inbound(self, inbound_id) -> Inbound:
|
|
135
135
|
"""Retrieve a specific inbound by ID.
|
|
136
136
|
|
|
137
137
|
Args:
|
|
138
|
-
|
|
138
|
+
inbound_id: The ID of the inbound to retrieve.
|
|
139
139
|
|
|
140
140
|
Returns:
|
|
141
141
|
An Inbound model instance for the specified ID.
|
|
142
142
|
"""
|
|
143
|
-
endpoint = f"/get/{
|
|
143
|
+
endpoint = f"/get/{inbound_id}"
|
|
144
144
|
json = await self._simple_get(f"{endpoint}")
|
|
145
|
-
inbound = Inbound(
|
|
145
|
+
inbound = Inbound(**json)
|
|
146
146
|
return inbound
|
|
147
147
|
|
|
148
148
|
|
|
@@ -163,7 +163,7 @@ class Clients(BaseEndpoint):
|
|
|
163
163
|
|
|
164
164
|
#although it's the same url, they should be differentiated
|
|
165
165
|
|
|
166
|
-
async def get_client_with_email(self, email: str) ->
|
|
166
|
+
async def get_client_with_email(self, email: str) -> ClientStats:
|
|
167
167
|
"""Retrieve client statistics by email.
|
|
168
168
|
|
|
169
169
|
Args:
|
|
@@ -174,9 +174,9 @@ class Clients(BaseEndpoint):
|
|
|
174
174
|
"""
|
|
175
175
|
endpoint = f"getClientTraffics/{email}"
|
|
176
176
|
resp = await self._simple_get(endpoint)
|
|
177
|
-
return
|
|
177
|
+
return ClientStats.model_validate(resp)
|
|
178
178
|
|
|
179
|
-
async def get_client_with_uuid(self, uuid: str) -> List[
|
|
179
|
+
async def get_client_with_uuid(self, uuid: str) -> List[ClientStats]:
|
|
180
180
|
"""Retrieve client statistics by UUID.
|
|
181
181
|
|
|
182
182
|
Args:
|
|
@@ -187,11 +187,10 @@ class Clients(BaseEndpoint):
|
|
|
187
187
|
"""
|
|
188
188
|
endpoint = f"getClientTrafficsById/{uuid}"
|
|
189
189
|
resp = await self._simple_get(endpoint)
|
|
190
|
-
client_stats =
|
|
190
|
+
client_stats = ClientStats.from_list(resp)
|
|
191
191
|
return client_stats
|
|
192
192
|
|
|
193
|
-
|
|
194
|
-
async def add_client(self, client: models.InboundClients | models.SingleInboundClient | Dict,
|
|
193
|
+
async def add_client(self, client: InboundClients | SingleInboundClient | Dict,
|
|
195
194
|
inbound_id: int | None = None) -> Response:
|
|
196
195
|
"""Add a new client to an inbound.
|
|
197
196
|
|
|
@@ -213,78 +212,73 @@ class Clients(BaseEndpoint):
|
|
|
213
212
|
endpoint = f"addClient"
|
|
214
213
|
if isinstance(client, Dict):
|
|
215
214
|
try:
|
|
216
|
-
|
|
217
|
-
final = models.InboundClients.model_validate_json(client)
|
|
215
|
+
final = InboundClients.model_validate(client)
|
|
218
216
|
except ValidationError:
|
|
219
|
-
#
|
|
220
|
-
tmp =
|
|
217
|
+
# Check the SingleInboundClient now...
|
|
218
|
+
tmp = SingleInboundClient.model_validate(client)
|
|
221
219
|
if inbound_id:
|
|
222
|
-
final =
|
|
223
|
-
|
|
220
|
+
final = InboundClients(id=inbound_id,
|
|
221
|
+
settings=ClientsSettings(clients=[tmp]))
|
|
224
222
|
else:
|
|
225
223
|
raise ValueError("A single client was provided to be added but no parent inbound id")
|
|
226
|
-
elif isinstance(client,
|
|
227
|
-
final =
|
|
228
|
-
|
|
229
|
-
elif isinstance(client,
|
|
224
|
+
elif isinstance(client, SingleInboundClient):
|
|
225
|
+
final = InboundClients(id=inbound_id,
|
|
226
|
+
settings=ClientsSettings(clients=[client]))
|
|
227
|
+
elif isinstance(client, InboundClients):
|
|
230
228
|
final = client
|
|
231
229
|
if inbound_id:
|
|
232
230
|
final.parent_id = inbound_id
|
|
233
231
|
else:
|
|
234
232
|
raise TypeError
|
|
235
233
|
# send request
|
|
236
|
-
data = final.
|
|
237
|
-
resp = await self.client.safe_post(f"{self._url}{endpoint}",
|
|
238
|
-
#YOU NEED TO PASS SETTINGS AS A STRING, NOT AS A DICT, YOU
|
|
234
|
+
data = json.loads(final.model_dump_json(by_alias=True))
|
|
235
|
+
resp = await self.client.safe_post(f"{self._url}{endpoint}", json=data)
|
|
236
|
+
#YOU NEED TO PASS SETTINGS AS A STRING, NOT AS A DICT, YOU IDIOT!
|
|
239
237
|
return resp
|
|
240
238
|
|
|
241
|
-
async def _request_update_client(self, client:
|
|
239
|
+
async def _request_update_client(self, client: InboundClients | SingleInboundClient,
|
|
242
240
|
inbound_id: int | None = None,
|
|
243
241
|
*, original_uuid: str | None = None) -> Response:
|
|
244
242
|
"""Request to update an existing client.
|
|
245
243
|
|
|
246
244
|
Args:
|
|
247
245
|
client: The client data to update. Can be:
|
|
246
|
+
- A ClientUpdatePayload - Recommended (requires inbound_id)
|
|
248
247
|
- A SingleInboundClient (requires inbound_id)
|
|
249
248
|
- An InboundClients object (with one client)
|
|
250
249
|
inbound_id: The ID of the inbound the client belongs to.
|
|
251
|
-
Required if client is a SingleInboundClient.
|
|
250
|
+
Required if client is a SingleInboundClient or ClientUpdatePayload.
|
|
252
251
|
original_uuid: The original UUID of the client to update.
|
|
253
|
-
Required if client is a SingleInboundClient.
|
|
252
|
+
Required if client is a SingleInboundClient or ClientUpdatePayload.
|
|
254
253
|
|
|
255
254
|
Returns:
|
|
256
255
|
The HTTP response from the API.
|
|
257
256
|
"""
|
|
258
|
-
if isinstance(client,
|
|
259
|
-
|
|
260
|
-
raise ValueError("Provide a parent inbound ID or pass models.InboundClients")
|
|
261
|
-
client = models.InboundClients(parent_id=inbound_id,
|
|
262
|
-
settings=models.InboundClients.Settings(clients=[client]))
|
|
263
|
-
else:
|
|
264
|
-
if len(client.settings.clients) != 1:
|
|
265
|
-
raise ValueError(f"You can only update 1 client at a time, instead got {len(client.settings.clients)}")
|
|
266
|
-
|
|
257
|
+
if isinstance(client, SingleInboundClient):
|
|
258
|
+
client = InboundClients(id=inbound_id, settings=ClientsSettings(clients=[client]))
|
|
267
259
|
_endpoint = f"updateClient/{original_uuid if original_uuid else client.settings.clients[0].uuid}"
|
|
268
|
-
|
|
269
|
-
|
|
260
|
+
#we have to do this because if we do model.dump() it will return a Settings **OBJECT** which we DON'T want.
|
|
261
|
+
resp = await self.client.safe_post(f"{self._url}{_endpoint}",
|
|
262
|
+
json=json.loads(client.model_dump_json(exclude_none=True, by_alias=True)))
|
|
270
263
|
return resp
|
|
271
264
|
|
|
272
|
-
|
|
265
|
+
|
|
266
|
+
async def update_single_client(self, inbound_id: int, client_uuid: str, *,
|
|
273
267
|
security: str | None = None,
|
|
274
268
|
password: str | None = None,
|
|
275
269
|
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
|
|
276
270
|
email: str | None = None,
|
|
277
271
|
limit_ip: int | None = None,
|
|
278
272
|
limit_gb: int | None = None,
|
|
279
|
-
expiry_time:
|
|
273
|
+
expiry_time: timestamp_seconds | None = None,
|
|
280
274
|
enable: bool | None = None,
|
|
281
275
|
sub_id: str | None = None,
|
|
282
276
|
comment: str | None = None,
|
|
283
|
-
):
|
|
277
|
+
) -> Response:
|
|
284
278
|
"""Update an existing client's details.
|
|
285
279
|
|
|
286
280
|
Args:
|
|
287
|
-
|
|
281
|
+
client_uuid: The UUID of the original client.
|
|
288
282
|
inbound_id: The ID of the inbound the client belongs to.
|
|
289
283
|
security: New security settings (optional).
|
|
290
284
|
password: New password (optional).
|
|
@@ -302,13 +296,17 @@ class Clients(BaseEndpoint):
|
|
|
302
296
|
"""
|
|
303
297
|
# Collect only the arguments that were explicitly provided (not None)
|
|
304
298
|
changes = {k: v for k, v in locals().items()
|
|
305
|
-
if k
|
|
299
|
+
if k not in ("self", "inbound_id", "client_uuid", "changes") and v is not None}
|
|
306
300
|
# Rename sub_id to subscription_id if needed
|
|
307
301
|
if 'sub_id' in changes:
|
|
308
302
|
changes['subscription_id'] = changes.pop('sub_id')
|
|
309
|
-
changes["updated_at"] = int(datetime.now(UTC).timestamp())
|
|
310
|
-
updated = existing_client.model_copy(update=changes)
|
|
311
303
|
|
|
304
|
+
found_inbound = await self.client._find_client_in_inbound(client_uuid, inbound_id)
|
|
305
|
+
if not found_inbound:
|
|
306
|
+
raise ClientDoesNotExistError(f"The target inbound was checked but client {client_uuid} was not found.")
|
|
307
|
+
|
|
308
|
+
changes["updated_at"] = int(datetime.now(UTC).timestamp())
|
|
309
|
+
updated = found_inbound.model_copy(update=changes)
|
|
312
310
|
resp = await self._request_update_client(updated, inbound_id)
|
|
313
311
|
return resp
|
|
314
312
|
|
python_3xui/models.py
CHANGED
|
@@ -4,6 +4,7 @@ from typing import Union, TypeAlias, Any, Annotated, Literal, List, Dict, ClassV
|
|
|
4
4
|
|
|
5
5
|
import pydantic
|
|
6
6
|
from pydantic import field_validator, Field, field_serializer
|
|
7
|
+
from typing_extensions import TypeVar
|
|
7
8
|
|
|
8
9
|
from . import base_model
|
|
9
10
|
from .util import JsonType, auto_s_to_ms_timestamp, auto_ms_to_s_timestamp
|
|
@@ -12,6 +13,7 @@ timestamp_seconds: TypeAlias = int
|
|
|
12
13
|
ip_address: TypeAlias = str
|
|
13
14
|
json_string: TypeAlias = str
|
|
14
15
|
|
|
16
|
+
|
|
15
17
|
def exclude_if_none(field) -> bool:
|
|
16
18
|
"""Check if a field value is None for exclusion purposes.
|
|
17
19
|
|
|
@@ -25,6 +27,10 @@ def exclude_if_none(field) -> bool:
|
|
|
25
27
|
return True
|
|
26
28
|
return False
|
|
27
29
|
|
|
30
|
+
|
|
31
|
+
_IntNone = TypeVar("_IntNone", bound=int | None)
|
|
32
|
+
|
|
33
|
+
|
|
28
34
|
class SingleInboundClient(pydantic.BaseModel):
|
|
29
35
|
"""Represents a single client within a VLESS/VMess inbound.
|
|
30
36
|
|
|
@@ -48,14 +54,15 @@ class SingleInboundClient(pydantic.BaseModel):
|
|
|
48
54
|
updated_at: Timestamp of last client update.
|
|
49
55
|
"""
|
|
50
56
|
TIME_FIELDS: ClassVar[List[str]] = ["expiry_time", "created_at", "updated_at"]
|
|
51
|
-
uuid:
|
|
57
|
+
uuid: Annotated[str, Field(alias="id")] #yes they really did that...
|
|
52
58
|
security: str = ""
|
|
53
59
|
password: str = ""
|
|
54
60
|
flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"]
|
|
55
61
|
email: Annotated[str, Field(alias="email")]
|
|
56
62
|
limit_ip: Annotated[int, Field(alias="limitIp")] = 20
|
|
63
|
+
reset: int = 0
|
|
57
64
|
#Interestingly, the API expects this value to be called GB but it's actually bytes.
|
|
58
|
-
limit_gb: Annotated[int, Field(alias="totalGB")] # total flow
|
|
65
|
+
limit_gb: Annotated[int, Field(alias="totalGB")] = 0 # total flow
|
|
59
66
|
expiry_time: Annotated[timestamp_seconds, Field(alias="expiryTime")] = 0
|
|
60
67
|
enable: bool = True
|
|
61
68
|
tg_id: Annotated[Union[int, str], Field(alias="tgId")] = ""
|
|
@@ -68,19 +75,35 @@ class SingleInboundClient(pydantic.BaseModel):
|
|
|
68
75
|
default_factory=(lambda: int(datetime.now(UTC).timestamp())))
|
|
69
76
|
]
|
|
70
77
|
|
|
71
|
-
|
|
78
|
+
# noinspection PyNestedDecorators
|
|
72
79
|
@field_validator(TIME_FIELDS[0], *TIME_FIELDS[1:], mode="after")
|
|
73
|
-
|
|
80
|
+
@classmethod
|
|
81
|
+
def ensure_s_timestamp(cls, value: _IntNone) -> _IntNone:
|
|
74
82
|
return auto_ms_to_s_timestamp(value)
|
|
75
83
|
|
|
76
|
-
|
|
84
|
+
# noinspection PyNestedDecorators
|
|
77
85
|
@field_serializer(TIME_FIELDS[0], *TIME_FIELDS[1:])
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
@classmethod
|
|
87
|
+
def serialize_ms_timestamp(cls, value: _IntNone) -> _IntNone:
|
|
88
|
+
return auto_s_to_ms_timestamp(value) if value is not None else None
|
|
80
89
|
|
|
90
|
+
# noinspection PyNestedDecorators
|
|
81
91
|
@field_serializer("limit_gb")
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
@classmethod
|
|
93
|
+
def serialize_total_gb(cls, value: _IntNone) -> _IntNone:
|
|
94
|
+
return value * (1024 ** 3) if value is not None else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ClientsSettings(pydantic.BaseModel):
|
|
98
|
+
"""Settings container for inbound clients.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
clients: List of SingleInboundClient objects.
|
|
102
|
+
"""
|
|
103
|
+
clients: list[SingleInboundClient]
|
|
104
|
+
decryption: Annotated[str, Field(exclude_if=lambda x: x == "none")] = "none"
|
|
105
|
+
encryption: Annotated[str, Field(exclude_if=lambda x: x == "none")] = "none"
|
|
106
|
+
fallbacks: Annotated[list|None, Field(exclude_if=exclude_if_none)] = None
|
|
84
107
|
|
|
85
108
|
|
|
86
109
|
class InboundClients(pydantic.BaseModel):
|
|
@@ -94,24 +117,16 @@ class InboundClients(pydantic.BaseModel):
|
|
|
94
117
|
settings: The settings object containing the client list.
|
|
95
118
|
"""
|
|
96
119
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Attributes:
|
|
101
|
-
clients: List of SingleInboundClient objects.
|
|
102
|
-
"""
|
|
103
|
-
clients: list[SingleInboundClient]
|
|
104
|
-
|
|
105
|
-
parent_id: Annotated[int|None, Field(exclude_if=exclude_if_none, alias="id")] = None
|
|
106
|
-
settings: Settings
|
|
120
|
+
parent_id: Annotated[int | None, Field(exclude_if=exclude_if_none, alias="id")] = None
|
|
121
|
+
settings: ClientsSettings
|
|
107
122
|
|
|
108
123
|
@field_serializer("settings")
|
|
109
|
-
def stringify_settings(self, value:
|
|
124
|
+
def stringify_settings(self, value: ClientsSettings) -> str:
|
|
110
125
|
"""Serialize the settings object to a JSON string.
|
|
111
126
|
|
|
112
127
|
The 3X-UI API expects settings as a JSON string, not an object.
|
|
113
128
|
"""
|
|
114
|
-
return json.dumps(value.model_dump(by_alias=True), ensure_ascii=False)
|
|
129
|
+
return json.dumps(value.model_dump(by_alias=True, exclude_none=True), ensure_ascii=False)
|
|
115
130
|
|
|
116
131
|
|
|
117
132
|
#class InboundSettings(base_model.BaseModel):
|
|
@@ -256,19 +271,30 @@ class Inbound(base_model.BaseModel):
|
|
|
256
271
|
expiryTime: timestamp_seconds # UNIX timestamp
|
|
257
272
|
trafficReset: str # "Never", "Weekly", "Monthly", "Daily"
|
|
258
273
|
lastTrafficResetTime: timestamp_seconds # UNIX timestamp
|
|
259
|
-
clientStats:
|
|
274
|
+
clientStats: list[ClientStats] | None
|
|
260
275
|
listen: str
|
|
261
276
|
port: int
|
|
262
277
|
protocol: Literal["vless", "vmess", "trojan", "shadowsocks", "wireguard"] # note: there are some "deprecated" like wireguard
|
|
263
|
-
settings:
|
|
278
|
+
settings: ClientsSettings # JSON packed value, stringified
|
|
264
279
|
streamSettings: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
|
|
265
280
|
tag: str
|
|
266
281
|
sniffing: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
|
|
267
282
|
|
|
268
|
-
|
|
269
|
-
@field_validator('settings', 'streamSettings', 'sniffing', mode='after')
|
|
283
|
+
@field_validator("settings", mode="before")
|
|
270
284
|
@classmethod
|
|
271
|
-
def
|
|
285
|
+
def parse_settings(cls, value: str) -> ClientsSettings:
|
|
286
|
+
if value == "":
|
|
287
|
+
return ClientsSettings(clients=[])
|
|
288
|
+
return ClientsSettings.model_validate_json(value, by_alias=True, extra="forbid")
|
|
289
|
+
|
|
290
|
+
@field_serializer("settings")
|
|
291
|
+
@classmethod
|
|
292
|
+
def dump_settings(cls, value: ClientsSettings) -> str:
|
|
293
|
+
return value.model_dump_json(by_alias=True)
|
|
294
|
+
|
|
295
|
+
@field_validator('streamSettings', 'sniffing', mode='after')
|
|
296
|
+
@classmethod
|
|
297
|
+
def parse_json_fields(cls, value: str) -> JsonType | Literal[""]:
|
|
272
298
|
"""Parse JSON string fields into dictionaries.
|
|
273
299
|
|
|
274
300
|
The 3X-UI API returns settings, streamSettings, and sniffing as
|
|
@@ -278,10 +304,9 @@ class Inbound(base_model.BaseModel):
|
|
|
278
304
|
return ""
|
|
279
305
|
return json.loads(value)
|
|
280
306
|
|
|
281
|
-
|
|
282
|
-
@field_serializer("settings", "streamSettings", "sniffing")
|
|
307
|
+
@field_serializer("streamSettings", "sniffing")
|
|
283
308
|
@classmethod
|
|
284
|
-
def stringify_json_fields(cls, value: Dict|Literal[""]) -> str:
|
|
309
|
+
def stringify_json_fields(cls, value: Dict | Literal[""]) -> str:
|
|
285
310
|
"""Serialize dictionary fields back to JSON strings.
|
|
286
311
|
|
|
287
312
|
When sending data back to the API, these fields must be JSON strings.
|
python_3xui/util.py
CHANGED
|
@@ -14,7 +14,7 @@ import logging
|
|
|
14
14
|
import random
|
|
15
15
|
import re
|
|
16
16
|
from datetime import UTC, datetime, tzinfo
|
|
17
|
-
from typing import TypeAlias, Union, Dict, Any, List
|
|
17
|
+
from typing import TypeAlias, Union, Dict, Any, List
|
|
18
18
|
|
|
19
19
|
import httpx
|
|
20
20
|
|
|
@@ -43,7 +43,7 @@ def camel_to_snake(name: str) -> str:
|
|
|
43
43
|
return re.sub(_RE_CAMEL_TO_SNAKE2, r"\1_\2", name).lower()
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
async def async_range(start, stop=None, step=1):
|
|
46
|
+
async def async_range(start: int, stop: int|None=None, step: int=1):
|
|
47
47
|
"""Async generator that yields values from a range.
|
|
48
48
|
|
|
49
49
|
This is an async version of the built-in range() function that yields
|
|
@@ -79,7 +79,7 @@ def base64_from_string(string: str, omit_trailing_equals: bool = False) -> str:
|
|
|
79
79
|
return base64.b64encode(bytes(str(string).encode("utf-8"))).decode()
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
def
|
|
82
|
+
def default_sub_from_tgid(telegram_id: int) -> str:
|
|
83
83
|
"""Generate a subscription ID from a Telegram ID.
|
|
84
84
|
|
|
85
85
|
Args:
|
|
@@ -124,6 +124,12 @@ def get_uuid_from_tgid(telegram_id: int, fixed: bool = True) -> str:
|
|
|
124
124
|
return f"{now.year}{mon}{day}-{hr}{mn}-1111-1111-{resid}"
|
|
125
125
|
|
|
126
126
|
|
|
127
|
+
def random_string(length: int):
|
|
128
|
+
s = "".join([random.choice(
|
|
129
|
+
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") for _ in range(length)
|
|
130
|
+
])
|
|
131
|
+
return s
|
|
132
|
+
|
|
127
133
|
def generate_random_email(length: int = 8) -> str:
|
|
128
134
|
"""Generate a random alphanumeric email identifier.
|
|
129
135
|
|
|
@@ -136,10 +142,7 @@ def generate_random_email(length: int = 8) -> str:
|
|
|
136
142
|
Examples:
|
|
137
143
|
>>> generate_random_email(8) # Random output like 'aB3xY9zQ'
|
|
138
144
|
"""
|
|
139
|
-
|
|
140
|
-
for i in range(length):
|
|
141
|
-
s += random.choice("1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
142
|
-
return s
|
|
145
|
+
return random_string(length)
|
|
143
146
|
|
|
144
147
|
|
|
145
148
|
def generate_email_from_tgid_inbid(telegram_id: int, /, inbound_id: int) -> str:
|
|
@@ -174,10 +177,7 @@ def generate_new_subscription(length: int = 16):
|
|
|
174
177
|
Examples:
|
|
175
178
|
>>> generate_new_subscription(16) # Random output like 'aB3xY9zQmNpL2kJh'
|
|
176
179
|
"""
|
|
177
|
-
|
|
178
|
-
for i in range(length):
|
|
179
|
-
s += random.choice("1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
180
|
-
return s
|
|
180
|
+
return random_string(length)
|
|
181
181
|
|
|
182
182
|
|
|
183
183
|
async def check_xui_response(response: JsonType | httpx.Response) -> str:
|
|
@@ -230,13 +230,13 @@ def get_days_until_expiry(expiry_time: int) -> float:
|
|
|
230
230
|
|
|
231
231
|
Returns:
|
|
232
232
|
Number of days until expiry. Returns negative value if already expired.
|
|
233
|
-
Returns a
|
|
233
|
+
Returns a 0 if expiry_time is 0 (no expiry).
|
|
234
234
|
|
|
235
235
|
Examples:
|
|
236
236
|
>>> get_days_until_expiry(int(datetime.now(UTC).timestamp()) + 86400) # 1 day from now
|
|
237
237
|
1.0
|
|
238
238
|
>>> get_days_until_expiry(0) # No expiry
|
|
239
|
-
|
|
239
|
+
0
|
|
240
240
|
"""
|
|
241
241
|
if expiry_time == 0:
|
|
242
242
|
return 0
|
|
@@ -283,6 +283,6 @@ def auto_ms_to_s_timestamp(ms_or_s: int) -> int:
|
|
|
283
283
|
return ms_to_s_timestamp(ms_or_s)
|
|
284
284
|
return ms_or_s
|
|
285
285
|
|
|
286
|
-
def datetime_now_ms(tzinfo: tzinfo|None) -> int:
|
|
286
|
+
def datetime_now_ms(tzinfo: tzinfo|None=UTC) -> int:
|
|
287
287
|
"""Get the current time as a UNIX timestamp in milliseconds."""
|
|
288
288
|
return int(datetime.now(tzinfo).timestamp()) * 1000
|
|
@@ -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,11 @@
|
|
|
1
|
+
python_3xui/__init__.py,sha256=tYFn1NixqukOf4c4JVsTFSXGvW-f_ErxqUbVJxYiJAU,100
|
|
2
|
+
python_3xui/api.py,sha256=D-3tA0i09Rhhv1wHfTbdefGN81BT4ESxMvM699R-SRA,25892
|
|
3
|
+
python_3xui/base_model.py,sha256=3jFVwPgMyVIck2W8vqzIz9cLookdleENUCCSIpdKISo,3211
|
|
4
|
+
python_3xui/custom_exceptions.py,sha256=hf25_vHhhYaAThhuVW8z-pNznlzU0j6yayVixP9d3XY,410
|
|
5
|
+
python_3xui/endpoints.py,sha256=HhO7z5IYp0gS0upKlfi3hcYwPPZWU17zWuElMwWOxnw,13391
|
|
6
|
+
python_3xui/models.py,sha256=B0BxOYFffsFn61YWHPj27VIS-N0J9uL5rUsRzV1pW1c,12262
|
|
7
|
+
python_3xui/util.py,sha256=SrOziaXxRj_YAHiUBjzmfRxWOKKLO80UgUUTnJ3tp-A,9239
|
|
8
|
+
python_3xui-0.0.9.dist-info/METADATA,sha256=evF8LSqVGIrbO9z9el67KIC5NvHv6AJvNyp_Hyb9C_8,1957
|
|
9
|
+
python_3xui-0.0.9.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
python_3xui-0.0.9.dist-info/licenses/LICENSE,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
|
|
11
|
+
python_3xui-0.0.9.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
python_3xui/__init__.py,sha256=Zb_-gNgvpe1LS95EKksaLFVM2wgWuXzoFb4pb3DZB_o,94
|
|
2
|
-
python_3xui/api.py,sha256=w7vPgQrvxnymzZtkLOqZb8nYjwdCuMrhklVVxyEC8ts,22347
|
|
3
|
-
python_3xui/base_model.py,sha256=qNQKS_oq5um5dAmpHSB5XhKy_06VJLXYu4eO9Jd50ac,3361
|
|
4
|
-
python_3xui/endpoints.py,sha256=6F7bfMV8i-DnRJx186YLWTjSdTqocdfGHSpZ7NxAt7E,13352
|
|
5
|
-
python_3xui/models.py,sha256=LjbzA9H6Zmrv3qRDQBIaxBiq7NACINiDpl0Uhvh_hK4,11369
|
|
6
|
-
python_3xui/util.py,sha256=PgnlUPQrysThhB5_PpwVdsQb94BF0LTEGC-XuIEgNU8,9277
|
|
7
|
-
python_3xui-0.0.8.dist-info/METADATA,sha256=nGsuWThDwbm4whPk-lBtZQeiJyFaD1MVjHfhPuVI4AY,1520
|
|
8
|
-
python_3xui-0.0.8.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
-
python_3xui-0.0.8.dist-info/licenses/LICENSE,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
|
|
10
|
-
python_3xui-0.0.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|