Python-3xui 0.0.13__tar.gz → 0.1.3__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.
Files changed (31) hide show
  1. python_3xui-0.1.3/PKG-INFO +84 -0
  2. python_3xui-0.1.3/README.md +56 -0
  3. {python_3xui-0.0.13 → python_3xui-0.1.3}/pyproject.toml +3 -3
  4. {python_3xui-0.0.13 → python_3xui-0.1.3}/python_3xui/__init__.py +1 -1
  5. {python_3xui-0.0.13 → python_3xui-0.1.3}/python_3xui/api.py +50 -19
  6. {python_3xui-0.0.13 → python_3xui-0.1.3}/python_3xui/api_core/client_service.py +178 -47
  7. {python_3xui-0.0.13 → python_3xui-0.1.3}/python_3xui/api_core/session_core.py +63 -14
  8. python_3xui-0.1.3/python_3xui/custom_exceptions.py +66 -0
  9. {python_3xui-0.0.13 → python_3xui-0.1.3}/python_3xui/models.py +19 -12
  10. python_3xui-0.1.3/tests/conftest.py +98 -0
  11. python_3xui-0.1.3/tests/test_client_service_hysteria.py +95 -0
  12. python_3xui-0.1.3/tests/test_models_hysteria.py +103 -0
  13. {python_3xui-0.0.13 → python_3xui-0.1.3}/tests/test_non_idempotent_endpoints_clients.py +9 -1
  14. {python_3xui-0.0.13 → python_3xui-0.1.3}/tests/test_xuiclient_helpers.py +139 -11
  15. python_3xui-0.0.13/PKG-INFO +0 -34
  16. python_3xui-0.0.13/README.md +0 -6
  17. python_3xui-0.0.13/python_3xui/custom_exceptions.py +0 -28
  18. python_3xui-0.0.13/tests/conftest.py +0 -65
  19. {python_3xui-0.0.13 → python_3xui-0.1.3}/.gitignore +0 -0
  20. {python_3xui-0.0.13 → python_3xui-0.1.3}/LICENSE +0 -0
  21. {python_3xui-0.0.13 → python_3xui-0.1.3}/python_3xui/api_core/__init__.py +0 -0
  22. {python_3xui-0.0.13 → python_3xui-0.1.3}/python_3xui/api_core/identity.py +0 -0
  23. {python_3xui-0.0.13 → python_3xui-0.1.3}/python_3xui/api_core/prod_cache.py +0 -0
  24. {python_3xui-0.0.13 → python_3xui-0.1.3}/python_3xui/base_model.py +0 -0
  25. {python_3xui-0.0.13 → python_3xui-0.1.3}/python_3xui/endpoints.py +0 -0
  26. {python_3xui-0.0.13 → python_3xui-0.1.3}/python_3xui/util.py +0 -0
  27. {python_3xui-0.0.13 → python_3xui-0.1.3}/tests/gather_response_stubs.py +0 -0
  28. {python_3xui-0.0.13 → python_3xui-0.1.3}/tests/pytest.ini +0 -0
  29. {python_3xui-0.0.13 → python_3xui-0.1.3}/tests/test_endpoints_clients.py +0 -0
  30. {python_3xui-0.0.13 → python_3xui-0.1.3}/tests/test_endpoints_inbounds.py +0 -0
  31. {python_3xui-0.0.13 → python_3xui-0.1.3}/tests/test_non_idempotent_endpoints_inbounds.py +0 -0
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: Python-3xui
3
+ Version: 0.1.3
4
+ Summary: 3x-ui wrapper for python
5
+ Project-URL: Homepage, https://github.com/Artem-Potapov/3x-py
6
+ Project-URL: Issues, https://github.com/Artem-Potapov/3x-py/issues
7
+ Author-email: JustMe_001 <justme001.causation755@passinbox.com>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: async-lru~=2.3.0
16
+ Requires-Dist: cachetools~=7.1.1
17
+ Requires-Dist: httpx~=0.28.1
18
+ Requires-Dist: pybase62~=1.0.0
19
+ Requires-Dist: pydantic<3,~=2.12.5
20
+ Requires-Dist: pyotp~=2.9.0
21
+ Requires-Dist: python-dotenv==1.2.2
22
+ Provides-Extra: testing
23
+ Requires-Dist: pytest; extra == 'testing'
24
+ Requires-Dist: pytest-asyncio; extra == 'testing'
25
+ Requires-Dist: pytest-dependency; extra == 'testing'
26
+ Requires-Dist: requests; extra == 'testing'
27
+ Description-Content-Type: text/markdown
28
+
29
+ <h1>Hi! This is my example python 3x-ui wrapper!</h1>
30
+ <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>
31
+ <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>
32
+
33
+ <h2>0.1.3 Release Notes</h2>
34
+ Adds Hysteria2 support for inbounds and client provisioning.
35
+
36
+ <h3>New: Hysteria2 support</h3>
37
+
38
+ - `Inbound.protocol` now accepts `"hysteria"` alongside `"vless"` and `"vmess"`.
39
+ - `SingleInboundClient` gains an `auth` field (the Hysteria2 secret). A new `model_validator`, `ensure_auth_or_id_present`, requires either `auth` (Hysteria2) or `uuid` (everything else). `uuid` now defaults to `""`, and both fields are dropped from the serialized payload when empty.
40
+ - `create_and_add_prod_client` detects `hysteria` inbounds and emits a random per-client `auth` instead of the deterministic Telegram-ID-derived UUID it uses for VLESS/VMess.
41
+
42
+ <h3>Misc</h3>
43
+
44
+ - New `trust_env` constructor kwarg, forwarded to `httpx.AsyncClient`. Defaults to `True`; set `False` to make httpx ignore the environment's proxy/SSL config — handy behind a socks4 proxy httpx can't use.
45
+
46
+ <h2>0.1.1r1 Release Notes</h2>
47
+ First "middle" version. Brings 3X-UI 3.0.0+ panel support and a client-side workaround for the panel's new duplicate-email behavior.
48
+
49
+ <h3>New: 3.0.0+ panel auth</h3>
50
+
51
+ Three new `XUIClient` constructor kwargs:
52
+
53
+ - `use_new_schema=True` — switches `login()` to the 3.0.0+ flow. Required for any 3.0.0+ panel.
54
+ - `bearer_token=...` — long-lived API token from the panel. Required when `use_new_schema=True`; wrapped in `SecretStr` internally and sent as `Authorization: Bearer <...>` on every request.
55
+ - `force_login=True` — additive on top of bearer. Coins a CSRF token (`/csrf-token`) and runs the cookie-login on top, for routes that still need it.
56
+
57
+ `SessionCore.login()` becomes a 3-branch dispatcher; the existing 2.X.X cookie-only flow is unchanged when `use_new_schema=False`.
58
+
59
+ Three new exceptions, all re-exported via `python_3xui.exceptions`:
60
+
61
+ - `SchemaMismatchError` — constructor-time. `use_new_schema=True` with no `bearer_token`.
62
+ - `InvalidBearerTokenError` — runtime. A 404 in pure-bearer mode would otherwise loop into `login()` re-setting the same (bad) header; the request now fast-fails instead.
63
+ - `CsrfTokenNotCoined` — runtime. `/csrf-token` didn't return a usable `obj`; the panel is almost certainly down.
64
+
65
+ <h3>New: client-side duplicate-email guard</h3>
66
+
67
+ 3X-UI 3.0.0+ silently accepts duplicate client emails on add instead of hard-rejecting them, which broke the `"duplicate email"` response-msg scan that drove `exist_ok` / `replace_if_exist` in `create_and_add_prod_client`. Workaround landed in this release:
68
+
69
+ - New `guard_duplicate_emails` `XUIClient` kwarg. Defaults to `use_new_schema` (on for 3.0.0+, off for 2.X.X). Pass `True`/`False` to force.
70
+ - New `guard_duplicates` per-call override on `create_and_add_prod_client`.
71
+ - When active, the method burst-probes `get_client_with_email` for every production inbound's deterministic `(tgid, inbid)` email before adding. Hits become a `conflicts: dict[int, ClientStats]` map; the panel add is skipped for those inbounds.
72
+ - `exist_ok`, `replace_if_exist`, and raise-on-default all keep their original semantics — the guard just feeds them. `replace_if_exist` even saves a round-trip vs the old path, since the guard already has the existing client's uuid.
73
+ - Return type widens to `dict[int, Response | None]`. `None` appears only with `exist_ok=True` and no `replace_if_exist`, for the inbounds the guard short-circuited.
74
+
75
+ <h3>Fixes & misc</h3>
76
+
77
+ - `SchemaMismatchError` message rendered as a tuple instead of formatted; switched the call site to an f-string.
78
+ - `tests/conftest.py` now requires `BEARER` in the env-var precheck; without it the fixture would raise `SchemaMismatchError` at construction instead of skipping cleanly.
79
+ - Stale `FIXME` comment removed from `SessionCore.login`.
80
+ - `cachetools` pin relaxed to `~=7.1.1`.
81
+ - Public exception surface now also exports `CsrfTokenNotCoined`, `SchemaMismatchError`, and `InvalidBearerTokenError` via `python_3xui.exceptions`.
82
+
83
+ <h2>0.0.12 Release Notes</h2>
84
+ Mainly just bugfixes. Fix email fallback, fix adding clients, add inbound management.
@@ -0,0 +1,56 @@
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.1.3 Release Notes</h2>
6
+ Adds Hysteria2 support for inbounds and client provisioning.
7
+
8
+ <h3>New: Hysteria2 support</h3>
9
+
10
+ - `Inbound.protocol` now accepts `"hysteria"` alongside `"vless"` and `"vmess"`.
11
+ - `SingleInboundClient` gains an `auth` field (the Hysteria2 secret). A new `model_validator`, `ensure_auth_or_id_present`, requires either `auth` (Hysteria2) or `uuid` (everything else). `uuid` now defaults to `""`, and both fields are dropped from the serialized payload when empty.
12
+ - `create_and_add_prod_client` detects `hysteria` inbounds and emits a random per-client `auth` instead of the deterministic Telegram-ID-derived UUID it uses for VLESS/VMess.
13
+
14
+ <h3>Misc</h3>
15
+
16
+ - New `trust_env` constructor kwarg, forwarded to `httpx.AsyncClient`. Defaults to `True`; set `False` to make httpx ignore the environment's proxy/SSL config — handy behind a socks4 proxy httpx can't use.
17
+
18
+ <h2>0.1.1r1 Release Notes</h2>
19
+ First "middle" version. Brings 3X-UI 3.0.0+ panel support and a client-side workaround for the panel's new duplicate-email behavior.
20
+
21
+ <h3>New: 3.0.0+ panel auth</h3>
22
+
23
+ Three new `XUIClient` constructor kwargs:
24
+
25
+ - `use_new_schema=True` — switches `login()` to the 3.0.0+ flow. Required for any 3.0.0+ panel.
26
+ - `bearer_token=...` — long-lived API token from the panel. Required when `use_new_schema=True`; wrapped in `SecretStr` internally and sent as `Authorization: Bearer <...>` on every request.
27
+ - `force_login=True` — additive on top of bearer. Coins a CSRF token (`/csrf-token`) and runs the cookie-login on top, for routes that still need it.
28
+
29
+ `SessionCore.login()` becomes a 3-branch dispatcher; the existing 2.X.X cookie-only flow is unchanged when `use_new_schema=False`.
30
+
31
+ Three new exceptions, all re-exported via `python_3xui.exceptions`:
32
+
33
+ - `SchemaMismatchError` — constructor-time. `use_new_schema=True` with no `bearer_token`.
34
+ - `InvalidBearerTokenError` — runtime. A 404 in pure-bearer mode would otherwise loop into `login()` re-setting the same (bad) header; the request now fast-fails instead.
35
+ - `CsrfTokenNotCoined` — runtime. `/csrf-token` didn't return a usable `obj`; the panel is almost certainly down.
36
+
37
+ <h3>New: client-side duplicate-email guard</h3>
38
+
39
+ 3X-UI 3.0.0+ silently accepts duplicate client emails on add instead of hard-rejecting them, which broke the `"duplicate email"` response-msg scan that drove `exist_ok` / `replace_if_exist` in `create_and_add_prod_client`. Workaround landed in this release:
40
+
41
+ - New `guard_duplicate_emails` `XUIClient` kwarg. Defaults to `use_new_schema` (on for 3.0.0+, off for 2.X.X). Pass `True`/`False` to force.
42
+ - New `guard_duplicates` per-call override on `create_and_add_prod_client`.
43
+ - When active, the method burst-probes `get_client_with_email` for every production inbound's deterministic `(tgid, inbid)` email before adding. Hits become a `conflicts: dict[int, ClientStats]` map; the panel add is skipped for those inbounds.
44
+ - `exist_ok`, `replace_if_exist`, and raise-on-default all keep their original semantics — the guard just feeds them. `replace_if_exist` even saves a round-trip vs the old path, since the guard already has the existing client's uuid.
45
+ - Return type widens to `dict[int, Response | None]`. `None` appears only with `exist_ok=True` and no `replace_if_exist`, for the inbounds the guard short-circuited.
46
+
47
+ <h3>Fixes & misc</h3>
48
+
49
+ - `SchemaMismatchError` message rendered as a tuple instead of formatted; switched the call site to an f-string.
50
+ - `tests/conftest.py` now requires `BEARER` in the env-var precheck; without it the fixture would raise `SchemaMismatchError` at construction instead of skipping cleanly.
51
+ - Stale `FIXME` comment removed from `SessionCore.login`.
52
+ - `cachetools` pin relaxed to `~=7.1.1`.
53
+ - Public exception surface now also exports `CsrfTokenNotCoined`, `SchemaMismatchError`, and `InvalidBearerTokenError` via `python_3xui.exceptions`.
54
+
55
+ <h2>0.0.12 Release Notes</h2>
56
+ Mainly just bugfixes. Fix email fallback, fix adding clients, add inbound management.
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "Python-3xui"
3
- version = "0.0.13"
3
+ version = "0.1.3"
4
4
  authors = [
5
5
  { name="JustMe_001", email="justme001.causation755@passinbox.com" },
6
6
  ]
7
7
  description = "3x-ui wrapper for python"
8
8
  readme = "README.md"
9
9
 
10
- requires-python = ">=3.12"
10
+ requires-python = ">=3.11"
11
11
  classifiers = [
12
12
  "Programming Language :: Python :: 3",
13
13
  "Operating System :: OS Independent",
@@ -25,7 +25,7 @@ dependencies = [
25
25
  "python-dotenv==1.2.2",
26
26
  "async_lru ~= 2.3.0",
27
27
  "pyotp ~= 2.9.0",
28
- "cachetools ~= 7.7.1",
28
+ "cachetools ~= 7.1.1",
29
29
  "pybase62 ~= 1.0.0"
30
30
  ]
31
31
 
@@ -4,7 +4,7 @@ from .api import XUIClient
4
4
  import python_3xui.custom_exceptions as exceptions
5
5
 
6
6
  __author__ = "JustMe_001"
7
- __version__ = "0.0.13"
7
+ __version__ = "0.1.3"
8
8
  __email__ = ""
9
9
 
10
10
 
@@ -49,8 +49,10 @@ class XUIClient:
49
49
  def __init__(self, base_website: str, base_port: int, base_path: str,
50
50
  *, username: str | None = None, password: str | None = None,
51
51
  two_fac_code: str | None = None, session_duration: int = 3600,
52
+ use_new_schema: bool = False, force_login: bool = False, bearer_token: str|None=None,
53
+ guard_duplicate_emails: bool | None = None,
52
54
  custom_prod_string: str = "testing",
53
- max_retries: int = 5, retry_delay: int = 1,
55
+ max_retries: int = 5, retry_delay: int = 1, trust_env: bool = True,
54
56
  custom_sub_generator: Callable[[int], str] | Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
55
57
  custom_uuid_generator: Callable[[int], str] | Callable[[int], Awaitable[str]] = util.get_uuid_from_tgid,
56
58
  panel_id: Any = None
@@ -63,12 +65,24 @@ class XUIClient:
63
65
  base_path: The base path for the API (e.g., "/panel").
64
66
  username: Username for authentication.
65
67
  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
+ two_fac_code: TOTP secret for 2FA. Short one-shot codes will \
69
+ work for the current login only.
70
+ use_new_schema: for 3X-UI 3.0.0 and above. Cardinally changes how logging in works.
71
+ force_login: Because of new schema in 3.0.0+, bearer is prevalent and login() is almost obsolete. \
72
+ Set to True if you need niche methods that still uses cookie-login. Omit for 2.X.X.
73
+ bearer_token: the bearer itself. Omit for 3X-UI 2.X.X.
74
+ guard_duplicate_emails: Client-side check for duplicate client emails
75
+ before adding (workaround for 3X-UI 3.0.0+ no longer hard-rejecting
76
+ duplicates). When None (default), resolves to ``use_new_schema``: on
77
+ for 3.0.0+ panels, off for 2.X.X. Pass True/False to force.
68
78
  session_duration: Maximum session duration in seconds. Defaults to 3600.
69
79
  custom_prod_string: Regex pattern used to select production inbounds.
70
80
  max_retries: Maximum retries for database-lock responses.
71
81
  retry_delay: Seconds to wait between database-lock retries.
82
+ trust_env: Forwarded to ``httpx.AsyncClient``. True (default) lets
83
+ httpx read proxy/SSL config from the environment (including the OS
84
+ proxy); set False to ignore it — e.g. behind a socks4 proxy httpx
85
+ cannot use.
72
86
  custom_sub_generator: Sync or async callable that receives a
73
87
  Telegram ID and returns the subscription ID for new clients.
74
88
  custom_uuid_generator: Sync or async callable that receives a
@@ -83,8 +97,12 @@ class XUIClient:
83
97
  password=password,
84
98
  two_fac_code=two_fac_code,
85
99
  session_duration=session_duration,
100
+ use_new_schema=use_new_schema,
101
+ force_login=force_login,
102
+ bearer_token=bearer_token,
86
103
  max_retries=max_retries,
87
104
  retry_delay=retry_delay,
105
+ trust_env=trust_env,
88
106
  panel_id=panel_id,
89
107
  )
90
108
  self._identity = IdentityResolver(custom_sub_generator, custom_uuid_generator)
@@ -101,11 +119,15 @@ class XUIClient:
101
119
  )
102
120
  self.PROD_STRING = self._prod_cache.PROD_STRING
103
121
  self.get_production_inbounds = self._prod_cache.get
122
+ _resolved_guard = (
123
+ use_new_schema if guard_duplicate_emails is None else guard_duplicate_emails
124
+ )
104
125
  self._tg_client_service = TgIDClientService(
105
126
  self.clients_end,
106
127
  self.inbounds_end,
107
128
  self._identity,
108
129
  self._prod_cache,
130
+ guard_duplicate_emails=_resolved_guard,
109
131
  )
110
132
 
111
133
  @property
@@ -367,7 +389,8 @@ class XUIClient:
367
389
  expiry_time: int = 0,
368
390
  exist_ok: bool = False,
369
391
  replace_if_exist: bool = False,
370
- ) -> dict[int, Response]:
392
+ guard_duplicates: bool | None = None,
393
+ ) -> dict[int, Response | None]:
371
394
  """Create and add a production client.
372
395
 
373
396
  This method creates a new client with the given Telegram ID and
@@ -380,23 +403,29 @@ class XUIClient:
380
403
  telegram_id: The Telegram ID of the client.
381
404
  additional_remark: An optional additional remark for the client.
382
405
  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.
406
+ exist_ok: If True, return API responses even when a duplicate is
407
+ detected. Inbounds where the guard short-circuited the add
408
+ appear in the result dict with a ``None`` value.
409
+ replace_if_exist: If True, inbounds with an existing client
410
+ (either guard-detected or panel-reported) will have that
411
+ client updated via ``request_update_client`` instead of
412
+ raising. The guard-detected path skips
413
+ ``_resolve_update_client`` because the uuid is already known.
414
+ guard_duplicates: Per-call override for the instance-level
415
+ ``guard_duplicate_emails`` flag. ``None`` (default) uses the
416
+ instance default; ``True`` / ``False`` force the guard on
417
+ or off for this call.
390
418
 
391
419
  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.
420
+ Dict[int, Response | None]: A mapping of inbound IDs to API
421
+ responses. ``None`` appears only when the guard short-circuited
422
+ the add for that inbound and ``replace_if_exist`` did not claim
423
+ it (reachable only with ``exist_ok=True``).
396
424
 
397
425
  Raises:
398
- ClientEmailAlreadyExistsError: If a duplicate client is reported,
399
- ``replace_if_exist`` is False, and ``exist_ok`` is False.
426
+ ClientEmailAlreadyExistsError: A duplicate was detected (by the
427
+ guard or by panel response) and neither ``exist_ok`` nor
428
+ ``replace_if_exist`` covered it.
400
429
  """
401
430
  return await self._tg_client_service.create_and_add_prod_client(
402
431
  telegram_id,
@@ -404,6 +433,7 @@ class XUIClient:
404
433
  expiry_time=expiry_time,
405
434
  exist_ok=exist_ok,
406
435
  replace_if_exist=replace_if_exist,
436
+ guard_duplicates=guard_duplicates,
407
437
  )
408
438
 
409
439
  async def update_client_by_tgid_only(self, telegram_id: int, prod_only: bool, /, *,
@@ -487,9 +517,10 @@ class XUIClient:
487
517
  enable: Whether the client is enabled (optional)
488
518
  sub_id: Subscription ID (optional)
489
519
  comment: Client comment/note (optional)
490
- email: New client email (optional). USE WITH CAUTION BECAUSE THE XUIClient WILL NOT TRACK THE NEW EMAIL.
520
+ email: New client email (optional). USE WITH CAUTION BECAUSE THE XUIClient WILL **NOT** TRACK THE NEW EMAIL.
491
521
  verbose: Enables guardrails.
492
- force_resolve_by_email: Whether to enable fetch-thru-email fallback when a client is not found, uses ~3 extra fetches but provides an extra layer of protection.
522
+ force_resolve_by_email: Whether to enable fetch-thru-email fallback when a client is not found, \
523
+ uses ~3 extra fetches but provides an extra layer of protection.
493
524
  Returns:
494
525
  Response from the API
495
526
  """
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ import random
7
8
  from asyncio import Task
8
9
  from datetime import datetime, UTC
9
10
  from typing import TYPE_CHECKING, List, Literal
@@ -24,18 +25,22 @@ if TYPE_CHECKING:
24
25
  class TgIDClientService:
25
26
  """Orchestrates TGID-derived flows against panel endpoints."""
26
27
 
27
- __slots__ = ("_clients", "_inbounds", "_identity", "_prod_cache")
28
+ __slots__ = ("_clients", "_inbounds", "_identity", "_prod_cache",
29
+ "_guard_duplicate_emails")
28
30
 
29
31
  def __init__(self,
30
32
  clients_endpoint: Clients,
31
33
  inbounds_endpoint: Inbounds,
32
34
  identity: IdentityResolver,
33
35
  prod_cache: ProductionInboundCache,
36
+ *,
37
+ guard_duplicate_emails: bool = False,
34
38
  ) -> None:
35
39
  self._clients = clients_endpoint
36
40
  self._inbounds = inbounds_endpoint
37
41
  self._identity = identity
38
42
  self._prod_cache = prod_cache
43
+ self._guard_duplicate_emails = guard_duplicate_emails
39
44
 
40
45
  async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> list[ClientStats]:
41
46
  #FIXME: Implement the fallback described in the docstring (api.py)
@@ -52,19 +57,68 @@ class TgIDClientService:
52
57
  expiry_time: int = 0,
53
58
  exist_ok: bool = False,
54
59
  replace_if_exist: bool = False,
55
- ) -> dict[int, Response]:
60
+ guard_duplicates: bool | None = None,
61
+ ) -> dict[int, Response | None]:
62
+ """Create and add a client across every production inbound.
63
+
64
+ Phase 0 (when the guard is active) probes each production inbound
65
+ for the deterministic ``(tgid, inbid)`` email; any hit becomes a
66
+ recorded conflict. Phase 1 burst-adds only non-conflicting
67
+ inbounds. Phase 2 (``replace_if_exist``) updates conflicting
68
+ inbounds in place. Phase 3 (``not exist_ok``) raises on any
69
+ conflict that was not claimed by Phase 2.
70
+
71
+ Args:
72
+ telegram_id: TG ID used to derive the client uuid and per-inbound email.
73
+ additional_remark: Optional remark prefix for the client's comment field.
74
+ expiry_time: UNIX timestamp in seconds. 0 = never expire.
75
+ exist_ok: When True, swallow duplicate-email conflicts; the result dict
76
+ will contain ``None`` for inbounds where the guard short-circuited
77
+ the add.
78
+ replace_if_exist: When True, conflicting inbounds are updated via
79
+ ``request_update_client`` using the existing client's uuid.
80
+ guard_duplicates: Per-call override for the instance-level
81
+ ``guard_duplicate_emails`` flag. ``None`` (default) uses the
82
+ instance default.
83
+
84
+ Returns:
85
+ Mapping of inbound id to the Response from the panel.
86
+ Entries are ``None`` only when the guard short-circuited the
87
+ add for that inbound and ``replace_if_exist`` did not claim it
88
+ (only reachable with ``exist_ok=True``).
89
+
90
+ Raises:
91
+ ClientEmailAlreadyExistsError: A duplicate was detected (by the
92
+ guard or by panel response) and neither ``exist_ok`` nor
93
+ ``replace_if_exist`` covered it.
94
+ """
95
+ guard_active = (
96
+ guard_duplicates if guard_duplicates is not None
97
+ else self._guard_duplicate_emails
98
+ )
99
+
56
100
  production_inbounds: tuple[Inbound, ...] = await self._prod_cache.get()
57
101
 
58
102
  custom_sub = await self._identity.resolve_sub(telegram_id)
59
- uuid = await self._identity.resolve_uuid(telegram_id)
103
+ base_uuid = await self._identity.resolve_uuid(telegram_id)
60
104
 
61
- # --- Phase 1: build clients and burst-add them ---
62
- _to_exec: list[asyncio.Task[Response]] = []
105
+ # Build one client template per inbound (used by Phase 1 and Phase 2).
63
106
  clients_by_inbound: dict[int, SingleInboundClient] = {}
64
107
  for inb in production_inbounds:
65
108
  tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
66
- client = SingleInboundClient(
109
+ if inb.protocol == "hysteria":
110
+ # Hysteria2 authenticates with a per-client secret, not a UUID.
111
+ #randomer = better, plus not to waste resources
112
+ auth = "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", k=12))
113
+ uuid = ""
114
+ else:
115
+ # Reset per inbound so a preceding hysteria inbound can't leak
116
+ # its empty UUID / random auth onto this (non-hysteria) one.
117
+ auth = ""
118
+ uuid = base_uuid
119
+ clients_by_inbound[inb.id] = SingleInboundClient(
67
120
  uuid=uuid,
121
+ auth=auth, #the empty one gets excluded, so it's fine
68
122
  flow="",
69
123
  email=tmp_email,
70
124
  limit_gb=0,
@@ -73,62 +127,117 @@ class TgIDClientService:
73
127
  comment=f"{additional_remark + ', ' if additional_remark else ''}created at {datetime.now(UTC)}",
74
128
  expiry_time=expiry_time,
75
129
  )
76
- clients_by_inbound[inb.id] = client
77
- _to_exec.append(
78
- asyncio.create_task(self._clients.add_client(client, inb.id))
79
- )
80
-
81
- raw_results: list[Response] = await asyncio.gather(*_to_exec)
82
-
83
- # Map inbound IDs to their add responses
84
- responses: dict[int, Response] = {}
85
- for i, inb in enumerate(production_inbounds):
86
- responses[inb.id] = raw_results[i]
87
130
 
88
- # --- Phase 2: replace duplicates when replace_if_exist is set ---
131
+ # --- Phase 0: guard lookup (parallel email probes) ---
132
+ conflicts: dict[int, ClientStats] = {}
133
+ if guard_active:
134
+ _probe_tasks: list[asyncio.Task[ClientStats | None]] = []
135
+ _probe_order: list[int] = []
136
+ for inb in production_inbounds:
137
+ tmp_email = clients_by_inbound[inb.id].email
138
+ _probe_tasks.append(asyncio.create_task(
139
+ self._detect_email_in_inbound(tmp_email, inb.id)
140
+ ))
141
+ _probe_order.append(inb.id)
142
+ _probe_results: list[ClientStats | None] = await asyncio.gather(*_probe_tasks)
143
+ for i, inb_id in enumerate(_probe_order):
144
+ if _probe_results[i] is not None:
145
+ conflicts[inb_id] = _probe_results[i]
146
+
147
+ # --- Phase 1: burst-add for clean inbounds; seed None for conflicts ---
148
+ responses: dict[int, Response | None] = {inb_id: None for inb_id in conflicts}
149
+
150
+ _add_tasks: list[asyncio.Task[Response]] = []
151
+ _add_order: list[int] = []
152
+ for inb in production_inbounds:
153
+ if inb.id in conflicts:
154
+ continue
155
+ _add_tasks.append(asyncio.create_task(
156
+ self._clients.add_client(clients_by_inbound[inb.id], inb.id)
157
+ ))
158
+ _add_order.append(inb.id)
159
+ if _add_tasks:
160
+ _add_results: list[Response] = await asyncio.gather(*_add_tasks)
161
+ for i, inb_id in enumerate(_add_order):
162
+ responses[inb_id] = _add_results[i]
163
+
164
+ # --- Phase 2: replace_if_exist (handles both guard-detected
165
+ # and panel-reported duplicates) ---
89
166
  if replace_if_exist:
90
- _update_exec: list[asyncio.Task[Response]] = []
91
- # Track which inbound each update task corresponds to via list index
92
- update_inbound_ids: list[int] = []
167
+ _update_tasks: list[asyncio.Task[Response]] = []
168
+ _update_order: list[int] = []
169
+
170
+ # Guard-detected conflicts: stashed stats.uuid is the existing client's uuid.
171
+ for inb_id, stats in conflicts.items():
172
+ client = clients_by_inbound[inb_id]
173
+ _update_tasks.append(asyncio.create_task(
174
+ self._clients.request_update_client(
175
+ client, inb_id, original_uuid=stats.uuid,
176
+ )
177
+ ))
178
+ _update_order.append(inb_id)
93
179
 
180
+ # Panel-reported duplicates from Phase 1 (still relevant on 2.X.X or
181
+ # any future panel that re-enables hard reject).
94
182
  for inb_id, resp in list(responses.items()):
183
+ if inb_id in conflicts:
184
+ continue # already queued above
185
+ if resp is None:
186
+ continue # only conflict-seeded entries are None at this point
95
187
  json_resp = resp.json()
96
188
  msg = json_resp.get("msg", "")
97
189
  if "duplicate email" in msg.lower():
98
190
  client = clients_by_inbound[inb_id]
99
- _update_exec.append(
100
- asyncio.create_task(
101
- self._clients.request_update_client(
102
- client, inb_id, original_uuid=client.uuid,
103
- )
191
+ _update_tasks.append(asyncio.create_task(
192
+ self._clients.request_update_client(
193
+ client, inb_id, original_uuid=client.uuid,
104
194
  )
105
- )
106
- update_inbound_ids.append(inb_id)
107
-
108
- if _update_exec:
109
- update_results: list[Response] = await asyncio.gather(*_update_exec)
110
- _search_update_exec: list[Task] = []
111
- _search_update_resp: dict[int, Task] = {}
112
- for i, inb_id in enumerate(update_inbound_ids):
113
- _resp = update_results[i]
114
- _msg: str = _resp.json()["msg"]
195
+ ))
196
+ _update_order.append(inb_id)
197
+
198
+ if _update_tasks:
199
+ _update_results: list[Response] = await asyncio.gather(*_update_tasks)
200
+ _resolve_tasks: list[asyncio.Task[Response]] = []
201
+ _resolve_order: list[int] = []
202
+ for i, inb_id in enumerate(_update_order):
203
+ _resp = _update_results[i]
204
+ if inb_id in conflicts:
205
+ # Guard-fed updates carried a valid uuid; no "empty client id" fallback needed.
206
+ if not _resp.json().get("success", True):
207
+ logging.warning(
208
+ "Guard-detected update for inbound %s returned failure: %s",
209
+ inb_id, _resp.json().get("msg"),
210
+ )
211
+ responses[inb_id] = _resp
212
+ continue
213
+ _msg = _resp.json().get("msg", "")
115
214
  if "empty client id" in _msg.lower():
116
- t = asyncio.create_task(
117
- self._resolve_update_client(telegram_id, inb_id, clients_by_inbound[inb_id])
118
- )
119
- _search_update_exec.append(t)
120
- _search_update_resp[inb_id] = t
215
+ _resolve_tasks.append(asyncio.create_task(
216
+ self._resolve_update_client(
217
+ telegram_id, inb_id, clients_by_inbound[inb_id]
218
+ )
219
+ ))
220
+ _resolve_order.append(inb_id)
121
221
  else:
122
222
  responses[inb_id] = _resp
223
+ if _resolve_tasks:
224
+ _resolve_results: list[Response] = await asyncio.gather(*_resolve_tasks)
225
+ for i, inb_id in enumerate(_resolve_order):
226
+ responses[inb_id] = _resolve_results[i]
123
227
 
124
- if _search_update_exec:
125
- await asyncio.gather(*_search_update_exec)
126
- for inb_id, task in _search_update_resp.items():
127
- responses[inb_id] = task.result()
128
-
129
- # --- Phase 3: raise on remaining duplicates if not exist_ok ---
228
+ # --- Phase 3: raise on unclaimed duplicates if not exist_ok ---
130
229
  if not exist_ok:
131
230
  for inb_id, resp in responses.items():
231
+ if resp is None:
232
+ # Guard-detected conflict that Phase 2 did not claim.
233
+ logging.error(
234
+ "Client already exists in inbound %s for tgid %s (guard-detected)",
235
+ inb_id, telegram_id,
236
+ )
237
+ raise ClientEmailAlreadyExistsError(
238
+ f"Client already exists in inbound {inb_id} for "
239
+ f"telegram_id {telegram_id} (guard-detected)"
240
+ )
132
241
  json_resp: dict = resp.json()
133
242
  msg = json_resp.get("msg", "")
134
243
  if "duplicate email" in msg.lower():
@@ -149,6 +258,28 @@ class TgIDClientService:
149
258
  inbound_client, inb_id, original_uuid=_found.uuid,
150
259
  )
151
260
 
261
+ async def _detect_email_in_inbound(self,
262
+ email: str,
263
+ inbound_id: int,
264
+ ) -> ClientStats | None:
265
+ """Return the panel's ClientStats record for ``email`` if it lives
266
+ in ``inbound_id``, else None. Used by the duplicate-email guard
267
+ before burst-add.
268
+
269
+ The returned ``ClientStats`` carries the existing client's
270
+ ``uuid``, which the guard's ``replace_if_exist`` branch reuses to
271
+ skip a round-trip through ``_resolve_update_client``.
272
+
273
+ Exceptions from ``get_client_with_email`` (network, auth,
274
+ validation) propagate unchanged.
275
+ """
276
+ stats = await self._clients.get_client_with_email(
277
+ email, raise_if_none=False
278
+ )
279
+ if stats is not None and stats.inboundId == inbound_id:
280
+ return stats
281
+ return None
282
+
152
283
  async def _find_client_in_inbound(self,
153
284
  client_uuid: str,
154
285
  inbound_id: int,