"""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")