102 lines
3.6 KiB
Python
102 lines
3.6 KiB
Python
"""Small MAX Bot API client. Secrets stay in headers; failures never expose response bodies."""
|
|
|
|
import logging
|
|
import ssl
|
|
|
|
import httpx
|
|
|
|
from app.config import Settings
|
|
|
|
API_URL = "https://platform-api2.max.ru"
|
|
UPDATE_TYPES = ["bot_started", "message_created", "bot_stopped", "dialog_removed"]
|
|
|
|
|
|
class _HideRecipient(logging.Filter):
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
# httpx INFO normally logs query strings, which include a MAX user_id.
|
|
return "platform-api2.max.ru" not in record.getMessage()
|
|
|
|
|
|
logging.getLogger("httpx").addFilter(_HideRecipient())
|
|
|
|
|
|
class MaxApiError(Exception):
|
|
def __init__(self, code: str, *, retryable: bool = False, retry_after: float = 0):
|
|
self.code, self.retryable, self.retry_after = code, retryable, retry_after
|
|
super().__init__(code)
|
|
|
|
|
|
class MaxClient:
|
|
def __init__(self, settings: Settings, *, transport: httpx.AsyncBaseTransport | None = None):
|
|
context = ssl.create_default_context()
|
|
if settings.max_ca_bundle:
|
|
context.load_verify_locations(cafile=str(settings.max_ca_bundle))
|
|
self.http = httpx.AsyncClient(
|
|
base_url=API_URL,
|
|
headers={"Authorization": settings.max_bot_token.get_secret_value()},
|
|
timeout=httpx.Timeout(5),
|
|
verify=context,
|
|
follow_redirects=False,
|
|
transport=transport,
|
|
)
|
|
|
|
async def close(self) -> None:
|
|
await self.http.aclose()
|
|
|
|
async def request(self, method: str, path: str, **kwargs) -> dict:
|
|
try:
|
|
response = await self.http.request(method, path, **kwargs)
|
|
except httpx.RequestError:
|
|
raise MaxApiError("transport", retryable=True) from None
|
|
if not response.is_success:
|
|
try:
|
|
delay = min(3600, max(0, float(response.headers.get("Retry-After", "0"))))
|
|
except ValueError:
|
|
delay = 0
|
|
raise MaxApiError(
|
|
f"http_{response.status_code}",
|
|
retryable=response.status_code in (408, 429) or response.status_code >= 500,
|
|
retry_after=delay,
|
|
)
|
|
try:
|
|
data = response.json()
|
|
except ValueError:
|
|
raise MaxApiError("invalid_response", retryable=True) from None
|
|
if not isinstance(data, dict):
|
|
raise MaxApiError("invalid_response", retryable=True)
|
|
if data.get("success") is False:
|
|
raise MaxApiError("rejected")
|
|
return data
|
|
|
|
async def send(self, user_id: int, text: str, *, site_url: str, button: str) -> None:
|
|
data = await self.request(
|
|
"POST",
|
|
"/messages",
|
|
params={"user_id": user_id, "disable_link_preview": "true"},
|
|
json={
|
|
"text": text,
|
|
"notify": True,
|
|
"attachments": [
|
|
{
|
|
"type": "inline_keyboard",
|
|
"payload": {"buttons": [[{"type": "link", "text": button, "url": site_url}]]},
|
|
}
|
|
],
|
|
},
|
|
)
|
|
if not isinstance(data.get("message"), dict):
|
|
raise MaxApiError("missing_message", retryable=True)
|
|
|
|
async def subscribe(self, settings: Settings) -> None:
|
|
data = await self.request(
|
|
"POST",
|
|
"/subscriptions",
|
|
json={
|
|
"url": settings.max_webhook_url,
|
|
"update_types": UPDATE_TYPES,
|
|
"secret": settings.max_webhook_secret.get_secret_value(),
|
|
},
|
|
)
|
|
if data.get("success") is not True:
|
|
raise MaxApiError("subscription_not_confirmed")
|