274 lines
12 KiB
Python
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
|