Files
awg-gus-bot/vendor/gusvoice_bot/client.py
T

274 lines
12 KiB
Python

# SPDX-License-Identifier: MIT
"""HTTP client of the GusVoice Bot API on the standard library only (`urllib`).
Every method is `POST <api>/api/bot/v1/<method>` with `Authorization: Bot <token>`; the answer is
`{"ok": true, "result": ...}` or `{"ok": false, "error_code", "description", "retry_after"?}`. A 429 is retried after
`retry_after` (up to `max_retries` times); other errors raise `GusVoiceApiError`, network errors raise as they are
(`urllib.error.URLError`, `TimeoutError`). The same behaviour as the TypeScript SDK (`packages/bot-sdk/src/apiClient.ts`).
"""
from __future__ import annotations
import asyncio
import json
import mimetypes
import os
import socket
import threading
import time
import urllib.error
import urllib.request
import uuid
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Sequence, Tuple, Union
from urllib.parse import urljoin
from ._generated import GeneratedMethods
from .errors import GusVoiceApiError
__all__ = ["Client", "AsyncClient", "InputFile", "normalize_base"]
DEFAULT_TIMEOUT_S = 30.0
"""Time limit of one request, seconds (long polling adds its own `timeout` on top)."""
DISCOVERY_TIMEOUT_S = 10.0
def normalize_base(url: str) -> str:
"""Without trailing `/` and without a trailing `/api`: the client adds the method path itself."""
u = str(url).strip().rstrip("/")
if u.endswith("/api"):
u = u[:-4]
if not (u.lower().startswith("http://") or u.lower().startswith("https://")):
raise ValueError(f'base_url must start with http:// or https:// (got "{url}")')
return u
class InputFile:
"""A file for `send_message(..., files=[...])`: a path, bytes or a binary file object."""
def __init__(self, source: Union[str, "os.PathLike[str]", bytes, bytearray, BinaryIO], filename: Optional[str] = None,
content_type: Optional[str] = None) -> None:
self.source = source
self.filename = filename
self.content_type = content_type
def read(self) -> Tuple[bytes, str, str]:
"""`(data, file name, content type)`."""
src = self.source
name = self.filename
if isinstance(src, (bytes, bytearray)):
data = bytes(src)
name = name or "file"
elif isinstance(src, (str, os.PathLike)):
path = os.fspath(src)
with open(path, "rb") as f:
data = f.read()
name = name or os.path.basename(path) or "file"
else:
data = src.read()
name = name or os.path.basename(str(getattr(src, "name", "") or "")) or "file"
ctype = self.content_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
return data, name, ctype
def _json_default(o: Any) -> Any:
to_dict = getattr(o, "to_dict", None) # InlineKeyboard and similar builders
if callable(to_dict):
return to_dict()
raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable")
def _dumps(value: Any) -> str:
return json.dumps(value, default=_json_default, ensure_ascii=False, separators=(",", ":"))
def _multipart(params: Dict[str, Any], files: Sequence[Any]) -> Tuple[bytes, str]:
"""`multipart/form-data`: parameters as fields (objects and arrays as JSON strings), files as `files[0]`, `files[1]` ..."""
boundary = f"gusvoice-{uuid.uuid4().hex}"
out: List[bytes] = []
def quote(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\r", " ").replace("\n", " ")
for k, v in params.items():
if v is None:
continue
if isinstance(v, bool):
text = "true" if v else "false"
elif isinstance(v, (dict, list, tuple)) or callable(getattr(v, "to_dict", None)):
text = _dumps(v)
else:
text = str(v)
out.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{quote(k)}"\r\n\r\n'.encode("utf-8"))
out.append(text.encode("utf-8") + b"\r\n")
for i, f in enumerate(files):
data, name, ctype = (f if isinstance(f, InputFile) else InputFile(f)).read()
out.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="files[{i}]"; filename="{quote(name)}"\r\n'
f"Content-Type: {ctype}\r\n\r\n".encode("utf-8")
)
out.append(data + b"\r\n")
out.append(f"--{boundary}--\r\n".encode("ascii"))
return b"".join(out), f"multipart/form-data; boundary={boundary}"
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""A redirect would carry the bot token to another address: answer it as an error instead."""
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
return None
class Client(GeneratedMethods):
"""Bot API client. `base_url` is the instance site (`https://voice.example.com`) or its API address.
The API address is found once through `<base_url>/config.json` (`apiUrl`), as the web client does; pass
`discover=False` to use `base_url` as is. All methods are generated from the OpenAPI document (`_generated.py`):
`send_message(chat_id, "text", reply_markup=...)`, `get_updates(offset=..., timeout=...)`, ...; any method by name —
`call("sendMessage", {...})`. Thread-safe: one client can be used from several threads.
"""
def __init__(
self,
token: str,
base_url: str,
*,
discover: bool = True,
max_retries: int = 3,
max_retry_after: float = 60,
timeout: float = DEFAULT_TIMEOUT_S,
opener: Optional[urllib.request.OpenerDirector] = None,
sleep: Callable[[float], Any] = time.sleep,
) -> None:
if not token or not isinstance(token, str):
raise ValueError("token is required (BotFather-style `gvb_...` from the GusVoice bot settings)")
self.token = token
self.base_url = normalize_base(base_url)
self.discover = discover
self.max_retries = max(0, int(max_retries))
self.max_retry_after = max_retry_after
self.timeout = timeout
self._opener = opener or urllib.request.build_opener(_NoRedirect)
self._sleep = sleep
self._api: Optional[str] = None
self._api_lock = threading.Lock()
# ── address ───────────────────────────────────────────────────────────────────────────────────────────────────
def api_url(self) -> str:
"""API address (`https://api.example.com`) after reading `config.json`; a network failure is not remembered."""
with self._api_lock:
if self._api is None:
self._api = self._discover_api()
return self._api
def _discover_api(self) -> str:
if not self.discover:
return self.base_url
req = urllib.request.Request(f"{self.base_url}/config.json", headers={"Accept": "application/json"})
try:
with self._opener.open(req, timeout=DISCOVERY_TIMEOUT_S) as r:
raw = r.read()
except urllib.error.HTTPError:
return self.base_url # no config.json here: base_url already is the API
except (TimeoutError, socket.timeout):
return self.base_url
except urllib.error.URLError as e:
if isinstance(e.reason, (TimeoutError, socket.timeout)):
return self.base_url
raise
try:
cfg = json.loads(raw.decode("utf-8"))
except ValueError:
return self.base_url
api = cfg.get("apiUrl") if isinstance(cfg, dict) else None
if not isinstance(api, str) or not api.strip():
return self.base_url
found = normalize_base(urljoin(f"{self.base_url}/", api.strip()))
# The site is https, but config.json points to plain http (spoofing or a misconfiguration): keep the token off it.
if self.base_url.lower().startswith("https:") and not found.lower().startswith("https:"):
raise ValueError(
f"config.json of {self.base_url} points apiUrl to non-HTTPS {found}; refusing to send the bot token over plain HTTP"
)
return found
def auth_header(self) -> str:
return f"Bot {self.token}"
# ── methods ───────────────────────────────────────────────────────────────────────────────────────────────────
def call(self, method: str, params: Optional[Dict[str, Any]] = None, *, files: Optional[Sequence[Any]] = None,
request_timeout: Optional[float] = None) -> Any:
"""Any Bot API method by name. `params` go as JSON; with `files` — as `multipart/form-data`."""
params = {k: v for k, v in (params or {}).items() if v is not None}
url = f"{self.api_url()}/api/bot/v1/{method}"
limit = request_timeout
if limit is None:
limit = self.timeout
if method.lower() == "getupdates": # long polling: the server holds the request up to `timeout` seconds
try:
limit += float(params.get("timeout") or 0)
except (TypeError, ValueError):
pass
attempt = 0
while True:
if files:
body, ctype = _multipart(params, files)
else:
body, ctype = _dumps(params).encode("utf-8"), "application/json"
req = urllib.request.Request(
url, data=body, method="POST",
headers={"Authorization": self.auth_header(), "Content-Type": ctype, "Accept": "application/json"},
)
retry_header: Optional[str] = None
try:
with self._opener.open(req, timeout=limit) as r:
status = r.status
raw = r.read()
except urllib.error.HTTPError as e:
status = e.code
retry_header = e.headers.get("Retry-After") if e.headers else None
raw = e.read() or b""
try:
res = json.loads(raw.decode("utf-8"))
except ValueError:
res = None
if isinstance(res, dict) and res.get("ok") is True:
return res.get("result")
err = res if isinstance(res, dict) and res.get("ok") is False else {}
code = err.get("error_code") if isinstance(err.get("error_code"), int) else status
description = err.get("description") if isinstance(err.get("description"), str) else f"HTTP {status}"
retry_after = err.get("retry_after")
if not isinstance(retry_after, int) or isinstance(retry_after, bool):
retry_after = int(retry_header) if retry_header and retry_header.strip().isdigit() else None
if code == 429 and retry_after is not None and attempt < self.max_retries and retry_after <= self.max_retry_after:
attempt += 1
self._sleep(retry_after)
continue
raise GusVoiceApiError(code, description, retry_after, method)
# ── receiving updates ─────────────────────────────────────────────────────────────────────────────────────────
def run_polling(self, handler: Callable[[Dict[str, Any]], Any], **options: Any) -> None:
"""Long polling loop (`getUpdates`) — see `gusvoice_bot.polling.run_polling` for the options."""
from .polling import run_polling
run_polling(self, handler, **options)
class AsyncClient:
"""`asyncio` wrapper over `Client`: every method runs in a worker thread (`asyncio.to_thread`).
It is threads, not native async I/O — fine for a bot, no extra dependencies. `await bot.send_message(chat, "hi")`.
"""
def __init__(self, token: str, base_url: str, **options: Any) -> None:
self.sync = Client(token, base_url, **options)
def __getattr__(self, name: str) -> Any:
attr = getattr(self.sync, name)
if not callable(attr):
return attr
async def run(*args: Any, **kwargs: Any) -> Any:
return await asyncio.to_thread(attr, *args, **kwargs)
run.__name__ = name
run.__doc__ = getattr(attr, "__doc__", None)
return run