maxbot-api-client-python 1.1.0__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.
- maxbot_api_client_python/__init__.py +7 -0
- maxbot_api_client_python/api.py +35 -0
- maxbot_api_client_python/client.py +195 -0
- maxbot_api_client_python/exceptions.py +36 -0
- maxbot_api_client_python/tools/__init__.py +8 -0
- maxbot_api_client_python/tools/bots.py +52 -0
- maxbot_api_client_python/tools/chats.py +445 -0
- maxbot_api_client_python/tools/helpers.py +259 -0
- maxbot_api_client_python/tools/messages.py +194 -0
- maxbot_api_client_python/tools/subscriptions.py +101 -0
- maxbot_api_client_python/tools/uploads.py +91 -0
- maxbot_api_client_python/types/__init__.py +4 -0
- maxbot_api_client_python/types/constants.py +109 -0
- maxbot_api_client_python/types/models.py +458 -0
- maxbot_api_client_python/utils.py +68 -0
- maxbot_api_client_python-1.1.0.dist-info/METADATA +241 -0
- maxbot_api_client_python-1.1.0.dist-info/RECORD +20 -0
- maxbot_api_client_python-1.1.0.dist-info/WHEEL +5 -0
- maxbot_api_client_python-1.1.0.dist-info/licenses/LICENSE +21 -0
- maxbot_api_client_python-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from maxbot_api_client_python.api import API
|
|
2
|
+
from maxbot_api_client_python.client import Config, Client
|
|
3
|
+
from maxbot_api_client_python.types import models
|
|
4
|
+
from maxbot_api_client_python.types import constants
|
|
5
|
+
from maxbot_api_client_python import utils
|
|
6
|
+
|
|
7
|
+
__all__ = ["API", "Config", "Client", "models", "constants", "utils"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any, Self
|
|
2
|
+
|
|
3
|
+
from maxbot_api_client_python.client import Client, Config
|
|
4
|
+
from maxbot_api_client_python import tools
|
|
5
|
+
|
|
6
|
+
class API:
|
|
7
|
+
def __init__(self, cfg: Config) -> None:
|
|
8
|
+
self.client = Client(cfg)
|
|
9
|
+
|
|
10
|
+
self.bots = tools.Bots(self.client)
|
|
11
|
+
self.chats = tools.Chats(self.client)
|
|
12
|
+
self.helpers = tools.Helpers(self.client)
|
|
13
|
+
self.messages = tools.Messages(self.client)
|
|
14
|
+
self.subscriptions = tools.Subscriptions(self.client)
|
|
15
|
+
self.uploads = tools.Uploads(self.client)
|
|
16
|
+
|
|
17
|
+
def __enter__(self) -> Self:
|
|
18
|
+
self.client.__enter__()
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
22
|
+
self.client.__exit__(exc_type, exc_val, exc_tb)
|
|
23
|
+
|
|
24
|
+
async def __aenter__(self) -> Self:
|
|
25
|
+
await self.client.__aenter__()
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
29
|
+
await self.client.__aexit__(exc_type, exc_val, exc_tb)
|
|
30
|
+
|
|
31
|
+
def close(self) -> None:
|
|
32
|
+
self.client.close()
|
|
33
|
+
|
|
34
|
+
async def aclose(self) -> None:
|
|
35
|
+
await self.client.aclose()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import asyncio, httpx, json, logging, time, threading
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
from maxbot_api_client_python.exceptions import build_api_error
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class RateLimiter:
|
|
11
|
+
def __init__(self, rps: int):
|
|
12
|
+
self._lock = threading.Lock()
|
|
13
|
+
self._alock = asyncio.Lock()
|
|
14
|
+
self.last_request_time = 0.0
|
|
15
|
+
self.set_limit(rps)
|
|
16
|
+
|
|
17
|
+
def set_limit(self, rps: int) -> None:
|
|
18
|
+
self.limit = rps
|
|
19
|
+
self.interval = 1.0 / rps if rps > 0 else 0.0
|
|
20
|
+
|
|
21
|
+
def wait(self) -> None:
|
|
22
|
+
if self.interval <= 0:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
with self._lock:
|
|
26
|
+
now = time.monotonic()
|
|
27
|
+
elapsed = now - self.last_request_time
|
|
28
|
+
delay = max(0.0, self.interval - elapsed)
|
|
29
|
+
self.last_request_time = now + delay
|
|
30
|
+
|
|
31
|
+
if delay > 0:
|
|
32
|
+
time.sleep(delay)
|
|
33
|
+
|
|
34
|
+
async def async_wait(self) -> None:
|
|
35
|
+
if self.interval <= 0:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
async with self._alock:
|
|
39
|
+
now = time.monotonic()
|
|
40
|
+
elapsed = now - self.last_request_time
|
|
41
|
+
delay = max(0.0, self.interval - elapsed)
|
|
42
|
+
self.last_request_time = now + delay
|
|
43
|
+
|
|
44
|
+
if delay > 0:
|
|
45
|
+
await asyncio.sleep(delay)
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Config:
|
|
49
|
+
base_url: str
|
|
50
|
+
token: str
|
|
51
|
+
timeout: int = 35
|
|
52
|
+
ratelimiter: int = 25
|
|
53
|
+
max_retries: int = 3
|
|
54
|
+
retry_delay_sec: int = 3
|
|
55
|
+
|
|
56
|
+
class Client:
|
|
57
|
+
def __init__(self, cfg: Config):
|
|
58
|
+
if not cfg.base_url or not cfg.token:
|
|
59
|
+
raise ValueError("base_url and token must be set")
|
|
60
|
+
|
|
61
|
+
self.base_url = cfg.base_url.rstrip("/") + "/"
|
|
62
|
+
self.token = cfg.token
|
|
63
|
+
|
|
64
|
+
self.timeout = cfg.timeout if cfg.timeout > 0 else 35
|
|
65
|
+
self.global_limiter = RateLimiter(cfg.ratelimiter if cfg.ratelimiter > 0 else 25)
|
|
66
|
+
self.max_retries = cfg.max_retries
|
|
67
|
+
self.retry_delay_sec = cfg.retry_delay_sec
|
|
68
|
+
|
|
69
|
+
self.headers = {"Authorization": self.token}
|
|
70
|
+
|
|
71
|
+
self._sync_client = httpx.Client(timeout=self.timeout)
|
|
72
|
+
self._async_client: httpx.AsyncClient | None = None
|
|
73
|
+
|
|
74
|
+
def __enter__(self) -> "Client":
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
78
|
+
self.close()
|
|
79
|
+
|
|
80
|
+
async def __aenter__(self) -> "Client":
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
84
|
+
await self.aclose()
|
|
85
|
+
|
|
86
|
+
async def get_async_client(self) -> httpx.AsyncClient:
|
|
87
|
+
if self._async_client is None:
|
|
88
|
+
self._async_client = httpx.AsyncClient(timeout=self.timeout)
|
|
89
|
+
return self._async_client
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
self._sync_client.close()
|
|
93
|
+
|
|
94
|
+
async def aclose(self) -> None:
|
|
95
|
+
if self._async_client:
|
|
96
|
+
await self._async_client.aclose()
|
|
97
|
+
|
|
98
|
+
def _build_url(self, path: str) -> str:
|
|
99
|
+
if path.startswith(("http://", "https://")):
|
|
100
|
+
return path
|
|
101
|
+
return urljoin(self.base_url, path.lstrip("/"))
|
|
102
|
+
|
|
103
|
+
def _prepare_data(self, payload: Any) -> Any:
|
|
104
|
+
if hasattr(payload, "model_dump"):
|
|
105
|
+
return payload.model_dump(exclude_none=True)
|
|
106
|
+
return payload
|
|
107
|
+
|
|
108
|
+
def request(
|
|
109
|
+
self,
|
|
110
|
+
method: str,
|
|
111
|
+
path: str,
|
|
112
|
+
query: dict[str, Any] | None = None,
|
|
113
|
+
payload: Any = None,
|
|
114
|
+
files: dict[str, Any] | None = None
|
|
115
|
+
) -> bytes:
|
|
116
|
+
self.global_limiter.wait()
|
|
117
|
+
|
|
118
|
+
headers = self.headers.copy()
|
|
119
|
+
if not files:
|
|
120
|
+
headers["Content-Type"] = "application/json"
|
|
121
|
+
|
|
122
|
+
response = self._sync_client.request(
|
|
123
|
+
method=method,
|
|
124
|
+
url=self._build_url(path),
|
|
125
|
+
params=query,
|
|
126
|
+
json=self._prepare_data(payload) if not files else None,
|
|
127
|
+
data=None if not files else payload,
|
|
128
|
+
files=files,
|
|
129
|
+
headers=headers
|
|
130
|
+
)
|
|
131
|
+
logger.debug(f"Response body: {response.text}")
|
|
132
|
+
return self._handle_response(response)
|
|
133
|
+
|
|
134
|
+
async def arequest(
|
|
135
|
+
self,
|
|
136
|
+
method: str,
|
|
137
|
+
path: str,
|
|
138
|
+
query: dict[str, Any] | None = None,
|
|
139
|
+
payload: Any = None,
|
|
140
|
+
files: dict[str, Any] | None = None
|
|
141
|
+
) -> bytes:
|
|
142
|
+
await self.global_limiter.async_wait()
|
|
143
|
+
|
|
144
|
+
api = await self.get_async_client()
|
|
145
|
+
headers = self.headers.copy()
|
|
146
|
+
if not files:
|
|
147
|
+
headers["Content-Type"] = "application/json"
|
|
148
|
+
|
|
149
|
+
response = await api.request(
|
|
150
|
+
method=method,
|
|
151
|
+
url=self._build_url(path),
|
|
152
|
+
params=query,
|
|
153
|
+
json=self._prepare_data(payload) if not files else None,
|
|
154
|
+
data=None if not files else payload,
|
|
155
|
+
files=files,
|
|
156
|
+
headers=headers
|
|
157
|
+
)
|
|
158
|
+
logger.debug(f"Response body: {response.text}")
|
|
159
|
+
return self._handle_response(response)
|
|
160
|
+
|
|
161
|
+
def _handle_response(self, response: httpx.Response) -> bytes:
|
|
162
|
+
if not (200 <= response.status_code < 300):
|
|
163
|
+
raise build_api_error(response)
|
|
164
|
+
return response.content
|
|
165
|
+
|
|
166
|
+
def _parse_response[T](self, data: bytes, model_class: type[T]) -> T:
|
|
167
|
+
if not data or data.strip() == b"":
|
|
168
|
+
return model_class()
|
|
169
|
+
if data.strip() == b'<retval>1</retval>':
|
|
170
|
+
return model_class()
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
parsed_json = json.loads(data)
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
decoded_snippet = data[:100].decode('utf-8', errors='replace')
|
|
176
|
+
logger.warning(f"Server returned non-JSON response: {decoded_snippet}")
|
|
177
|
+
return model_class()
|
|
178
|
+
|
|
179
|
+
if hasattr(model_class, "model_validate"):
|
|
180
|
+
return model_class.model_validate(parsed_json)
|
|
181
|
+
return parsed_json
|
|
182
|
+
|
|
183
|
+
def decode[T](client: Client, method: str, path: str, model_class: type[T], **kwargs: Any) -> T:
|
|
184
|
+
response = client.request(method, path, **kwargs)
|
|
185
|
+
return client._parse_response(response, model_class)
|
|
186
|
+
|
|
187
|
+
async def adecode[T](client: Client, method: str, path: str, model_class: type[T], **kwargs: Any) -> T:
|
|
188
|
+
response = await client.arequest(method, path, **kwargs)
|
|
189
|
+
return client._parse_response(response, model_class)
|
|
190
|
+
|
|
191
|
+
def as_async(func: Any) -> Any:
|
|
192
|
+
@wraps(func)
|
|
193
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
194
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|
|
195
|
+
return wrapper
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
class MaxBotError(Exception):
|
|
4
|
+
default_message = "unexpected API error"
|
|
5
|
+
|
|
6
|
+
def __init__(self, message: str | None = None, status_code: int | None = None, response: str | None = None):
|
|
7
|
+
msg = message or self.default_message
|
|
8
|
+
full_message = f"{msg}: status code {status_code}, response: {response}"
|
|
9
|
+
super().__init__(full_message)
|
|
10
|
+
self.status_code = status_code
|
|
11
|
+
self.response = response
|
|
12
|
+
|
|
13
|
+
class BadRequestError(MaxBotError): default_message = "bad request"
|
|
14
|
+
class UnauthorizedError(MaxBotError): default_message = "unauthorized"
|
|
15
|
+
class NotFoundError(MaxBotError): default_message = "not found"
|
|
16
|
+
class MethodNotAllowedError(MaxBotError): default_message = "method not allowed"
|
|
17
|
+
class TooManyRequestsError(MaxBotError): default_message = "too many requests"
|
|
18
|
+
class ServiceUnavailableError(MaxBotError): default_message = "service unavailable"
|
|
19
|
+
|
|
20
|
+
_ERROR_MAP = {
|
|
21
|
+
400: BadRequestError,
|
|
22
|
+
401: UnauthorizedError,
|
|
23
|
+
404: NotFoundError,
|
|
24
|
+
405: MethodNotAllowedError,
|
|
25
|
+
429: TooManyRequestsError,
|
|
26
|
+
503: ServiceUnavailableError,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def get_exception_for_status(status_code: int) -> type[MaxBotError]:
|
|
30
|
+
return _ERROR_MAP.get(status_code, MaxBotError)
|
|
31
|
+
|
|
32
|
+
def build_api_error(response: httpx.Response) -> MaxBotError:
|
|
33
|
+
body_str = response.text
|
|
34
|
+
ExceptionClass = get_exception_for_status(response.status_code)
|
|
35
|
+
|
|
36
|
+
return ExceptionClass(status_code=response.status_code, response=body_str)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from maxbot_api_client_python.tools.bots import Bots
|
|
2
|
+
from maxbot_api_client_python.tools.chats import Chats
|
|
3
|
+
from maxbot_api_client_python.tools.messages import Messages
|
|
4
|
+
from maxbot_api_client_python.tools.subscriptions import Subscriptions
|
|
5
|
+
from maxbot_api_client_python.tools.uploads import Uploads
|
|
6
|
+
from maxbot_api_client_python.tools.helpers import Helpers
|
|
7
|
+
|
|
8
|
+
__all__ = ["Bots", "Chats", "Messages", "Subscriptions", "Uploads", "Helpers"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from maxbot_api_client_python.client import Client, decode, adecode
|
|
2
|
+
from maxbot_api_client_python.types.constants import Paths
|
|
3
|
+
from maxbot_api_client_python.types.models import BotInfo, BotPatch
|
|
4
|
+
|
|
5
|
+
class Bots:
|
|
6
|
+
def __init__(self, client: Client):
|
|
7
|
+
self.client = client
|
|
8
|
+
|
|
9
|
+
def GetBot(self) -> BotInfo:
|
|
10
|
+
"""
|
|
11
|
+
Retrieves information about the current bot, such as its user ID, name, and description.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
response = api.bots.GetBot()
|
|
15
|
+
"""
|
|
16
|
+
return decode(self.client, "GET", Paths.ME, BotInfo)
|
|
17
|
+
|
|
18
|
+
def PatchBot(self, **kwargs) -> BotInfo:
|
|
19
|
+
"""
|
|
20
|
+
Edits current bot info.
|
|
21
|
+
Fill only the fields you want to update - all remaining fields will stay untouched.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
response = api.bots.PatchBot(
|
|
25
|
+
name="New Name",
|
|
26
|
+
description="New description"
|
|
27
|
+
)
|
|
28
|
+
"""
|
|
29
|
+
req = BotPatch(**kwargs)
|
|
30
|
+
return decode(self.client, "PATCH", Paths.ME, BotInfo, payload=req)
|
|
31
|
+
|
|
32
|
+
async def GetBotAsync(self) -> BotInfo:
|
|
33
|
+
"""
|
|
34
|
+
Async version of GetBot.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
response = await api.bots.GetBotAsync()
|
|
38
|
+
"""
|
|
39
|
+
return await adecode(self.client, "GET", Paths.ME, BotInfo)
|
|
40
|
+
|
|
41
|
+
async def PatchBotAsync(self, **kwargs) -> BotInfo:
|
|
42
|
+
"""
|
|
43
|
+
Async version of PatchBot.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
response = await api.bots.PatchBotAsync(
|
|
47
|
+
name="New Name",
|
|
48
|
+
description="New description"
|
|
49
|
+
)
|
|
50
|
+
"""
|
|
51
|
+
req = BotPatch(**kwargs)
|
|
52
|
+
return await adecode(self.client, "PATCH", Paths.ME, BotInfo, payload=req)
|