onbot 0.1.0.dev0__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.
onbot/clients/base.py ADDED
@@ -0,0 +1,243 @@
1
+ """Async HTTP base client shared by the Authentik, Synapse-admin and (later) Matrix CS clients.
2
+
3
+ AD-7: one pooled ``httpx.AsyncClient`` with bearer-auth injection, retries on transient failures
4
+ (tenacity), a generic pagination helper, and typed errors. This replaces the legacy per-call
5
+ ``requests`` churn and the ``access_token.lstrip("Bearer ")`` token-corruption bug (BATTLE_PLAN §3):
6
+ tokens are stored bare and the ``Bearer`` prefix is added exactly once here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
12
+ from types import TracebackType
13
+ from typing import Any
14
+
15
+ import httpx
16
+ from tenacity import (
17
+ AsyncRetrying,
18
+ retry_if_exception,
19
+ stop_after_attempt,
20
+ wait_exponential,
21
+ )
22
+
23
+ from onbot.auth.token_provider import StaticTokenProvider, TokenProvider
24
+ from onbot.logging import get_logger
25
+
26
+ log = get_logger(__name__)
27
+
28
+ # Transient HTTP statuses worth retrying (rate-limit + gateway/server errors).
29
+ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 500, 502, 503, 504})
30
+
31
+
32
+ class ApiError(Exception):
33
+ """A non-2xx API response, carrying enough context to debug without re-reading logs."""
34
+
35
+ def __init__(
36
+ self,
37
+ method: str,
38
+ url: str,
39
+ status_code: int,
40
+ payload: Any = None,
41
+ ) -> None:
42
+ self.method = method
43
+ self.url = url
44
+ self.status_code = status_code
45
+ self.payload = payload
46
+ super().__init__(f"{method} {url} -> HTTP {status_code}: {payload!r}")
47
+
48
+ @property
49
+ def is_retryable(self) -> bool:
50
+ return self.status_code in RETRYABLE_STATUS_CODES
51
+
52
+
53
+ def _is_retryable_exc(exc: BaseException) -> bool:
54
+ if isinstance(exc, ApiError):
55
+ return exc.is_retryable
56
+ # Connection resets, timeouts, etc. are worth retrying; bad requests are not.
57
+ return isinstance(exc, httpx.TransportError)
58
+
59
+
60
+ class BaseApiClient:
61
+ """Thin async wrapper over ``httpx.AsyncClient`` with auth, retries and pagination."""
62
+
63
+ def __init__(
64
+ self,
65
+ base_url: str,
66
+ auth_token: str | None = None,
67
+ *,
68
+ token_provider: TokenProvider | None = None,
69
+ max_retry_attempts: int = 4,
70
+ timeout: float = 30.0,
71
+ client: httpx.AsyncClient | None = None,
72
+ ) -> None:
73
+ self._base_url = base_url.rstrip("/") + "/"
74
+ self._max_retry_attempts = max_retry_attempts
75
+ # Auth is resolved per request (AD-6): a static token or an OAuth2 provider that may
76
+ # rotate the token underneath this long-lived client. A bare token is just the static case.
77
+ if token_provider is None:
78
+ if auth_token is None:
79
+ raise ValueError("BaseApiClient needs either auth_token or token_provider")
80
+ token_provider = StaticTokenProvider(auth_token)
81
+ self._token_provider = token_provider
82
+ headers = {"Accept": "application/json"}
83
+ self._client = client or httpx.AsyncClient(headers=headers, timeout=timeout)
84
+ # When an external client is injected (tests), make sure the Accept header is present.
85
+ if client is not None:
86
+ self._client.headers.update(headers)
87
+
88
+ async def __aenter__(self) -> BaseApiClient:
89
+ return self
90
+
91
+ async def __aexit__(
92
+ self,
93
+ exc_type: type[BaseException] | None,
94
+ exc: BaseException | None,
95
+ tb: TracebackType | None,
96
+ ) -> None:
97
+ await self.aclose()
98
+
99
+ async def aclose(self) -> None:
100
+ await self._client.aclose()
101
+ await self._token_provider.aclose()
102
+
103
+ def _build_url(self, path: str) -> str:
104
+ # Absolute URLs pass through unchanged so callers can reach sibling APIs off the same
105
+ # host (e.g. the Matrix media repo at /_matrix/media, outside this client's base path).
106
+ if path.startswith(("http://", "https://")):
107
+ return path
108
+ return f"{self._base_url}{path.lstrip('/')}"
109
+
110
+ async def _auth_headers(self) -> dict[str, str]:
111
+ """Per-request Authorization header (the token may have rotated; see AD-6)."""
112
+ return {"Authorization": f"Bearer {await self._token_provider.get_token()}"}
113
+
114
+ async def _with_retry(self, do: Callable[[], Awaitable[Any]]) -> Any:
115
+ async for attempt in AsyncRetrying(
116
+ stop=stop_after_attempt(self._max_retry_attempts),
117
+ wait=wait_exponential(multiplier=0.5, max=10),
118
+ retry=retry_if_exception(_is_retryable_exc),
119
+ reraise=True,
120
+ ):
121
+ with attempt:
122
+ return await do()
123
+ raise AssertionError("unreachable") # pragma: no cover
124
+
125
+ async def request_json(
126
+ self,
127
+ method: str,
128
+ path: str,
129
+ *,
130
+ params: Mapping[str, Any] | None = None,
131
+ json_body: Any = None,
132
+ ) -> Any:
133
+ """Perform a request with retries, returning decoded JSON (``None`` for empty bodies)."""
134
+ url = self._build_url(path)
135
+ # Drop ``None`` query params so callers can pass optional filters uniformly.
136
+ clean_params = {k: v for k, v in (params or {}).items() if v is not None}
137
+
138
+ async def _do() -> Any:
139
+ headers = await self._auth_headers()
140
+ response = await self._client.request(
141
+ method, url, params=clean_params or None, json=json_body, headers=headers
142
+ )
143
+ if response.status_code >= 400:
144
+ raise ApiError(method, url, response.status_code, _safe_payload(response))
145
+ if not response.content:
146
+ return None
147
+ return response.json()
148
+
149
+ return await self._with_retry(_do)
150
+
151
+ async def request_raw(
152
+ self,
153
+ method: str,
154
+ path: str,
155
+ *,
156
+ params: Mapping[str, Any] | None = None,
157
+ content: bytes | None = None,
158
+ headers: Mapping[str, str] | None = None,
159
+ parse_json: bool = True,
160
+ ) -> Any:
161
+ """Like :meth:`request_json` but for raw byte bodies (media upload/download, MSC3916).
162
+
163
+ Sends ``content`` verbatim with the caller's ``headers`` (e.g. ``Content-Type``). Returns
164
+ decoded JSON when ``parse_json`` is true (upload responses), else the raw response bytes
165
+ (authenticated media downloads).
166
+ """
167
+ url = self._build_url(path)
168
+ clean_params = {k: v for k, v in (params or {}).items() if v is not None}
169
+
170
+ async def _do() -> Any:
171
+ req_headers = await self._auth_headers()
172
+ if headers:
173
+ req_headers.update(headers)
174
+ response = await self._client.request(
175
+ method, url, params=clean_params or None, content=content, headers=req_headers
176
+ )
177
+ if response.status_code >= 400:
178
+ raise ApiError(method, url, response.status_code, _safe_payload(response))
179
+ if not parse_json:
180
+ return response.content
181
+ if not response.content:
182
+ return None
183
+ return response.json()
184
+
185
+ return await self._with_retry(_do)
186
+
187
+ async def get_json(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
188
+ return await self.request_json("GET", path, params=params)
189
+
190
+ async def post_json(self, path: str, *, json_body: Any = None) -> Any:
191
+ return await self.request_json("POST", path, json_body=json_body)
192
+
193
+ async def put_json(self, path: str, *, json_body: Any = None) -> Any:
194
+ return await self.request_json("PUT", path, json_body=json_body)
195
+
196
+ async def delete_json(self, path: str, *, json_body: Any = None) -> Any:
197
+ return await self.request_json("DELETE", path, json_body=json_body)
198
+
199
+ async def paginate(
200
+ self,
201
+ path: str,
202
+ *,
203
+ params: Mapping[str, Any] | None = None,
204
+ extract_items: Callable[[Any], list[Any]],
205
+ next_params: Callable[[Any, dict[str, Any]], dict[str, Any] | None],
206
+ ) -> AsyncIterator[Any]:
207
+ """Yield items across all pages (fixes the legacy 'no pagination → silent truncation' bug).
208
+
209
+ ``extract_items`` pulls the item list out of a page; ``next_params`` returns the query params
210
+ for the next page given the current page and current params, or ``None`` when exhausted.
211
+ """
212
+ current: dict[str, Any] = dict(params or {})
213
+ while True:
214
+ page = await self.get_json(path, params=current)
215
+ for item in extract_items(page):
216
+ yield item
217
+ follow = next_params(page, current)
218
+ if follow is None:
219
+ return
220
+ current = follow
221
+
222
+ async def paginate_collect(
223
+ self,
224
+ path: str,
225
+ *,
226
+ params: Mapping[str, Any] | None = None,
227
+ extract_items: Callable[[Any], list[Any]],
228
+ next_params: Callable[[Any, dict[str, Any]], dict[str, Any] | None],
229
+ ) -> list[Any]:
230
+ items: list[Any] = []
231
+ async for item in self.paginate(
232
+ path, params=params, extract_items=extract_items, next_params=next_params
233
+ ):
234
+ items.append(item)
235
+ return items
236
+
237
+
238
+ def _safe_payload(response: httpx.Response) -> Any:
239
+ """Best-effort decode of an error body for diagnostics (APIs often embed helpful detail)."""
240
+ try:
241
+ return response.json()
242
+ except ValueError, UnicodeDecodeError:
243
+ return response.text
@@ -0,0 +1,65 @@
1
+ """MAS admin API client (async).
2
+
3
+ Under the MAS topology (ADR-0006) the Matrix access token is owned by MAS, not Synapse. The Phase 7b
4
+ integration experiment (§7 Q1) proved that the Synapse admin API cannot revoke a live session —
5
+ deleting devices or deactivating the account leaves the MAS-issued token valid. Effective lockout
6
+ must go through MAS itself. This client wraps the MAS admin API operations the lifecycle module needs:
7
+
8
+ * lock / unlock a user (reversible session revocation — the cooldown ``logout`` stage),
9
+ * deactivate a user (irreversible — the ``erase`` stage).
10
+
11
+ Auth is an OAuth2 ``client_credentials`` token carrying the ``urn:mas:admin`` scope (the bot's MAS
12
+ admin client must be listed in MAS ``policy.data.admin_clients``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from onbot.auth.token_provider import TokenProvider
20
+ from onbot.clients.base import ApiError, BaseApiClient
21
+ from onbot.logging import get_logger
22
+
23
+ log = get_logger(__name__)
24
+
25
+
26
+ def mxid_localpart(mxid: str) -> str:
27
+ """Extract the localpart from a full MXID (``@local:server`` -> ``local``)."""
28
+ return mxid.removeprefix("@").split(":", 1)[0]
29
+
30
+
31
+ class ApiClientMasAdmin(BaseApiClient):
32
+ """The subset of the MAS admin API onbot's lifecycle module uses."""
33
+
34
+ def __init__(
35
+ self,
36
+ mas_url: str,
37
+ access_token: str | None = None,
38
+ *,
39
+ token_provider: TokenProvider | None = None,
40
+ **kwargs: Any,
41
+ ) -> None:
42
+ base = f"{mas_url.rstrip('/')}/api/admin/v1"
43
+ super().__init__(base_url=base, auth_token=access_token, token_provider=token_provider, **kwargs)
44
+
45
+ async def get_user_id_by_username(self, username: str) -> str | None:
46
+ """Resolve a MAS user ULID from its username (localpart), or ``None`` if unknown."""
47
+ try:
48
+ result = await self.get_json(f"users/by-username/{username}")
49
+ except ApiError as exc:
50
+ if exc.status_code == 404:
51
+ return None
52
+ raise
53
+ user_id: str = result["data"]["id"]
54
+ return user_id
55
+
56
+ async def lock_user(self, user_id: str) -> None:
57
+ """Lock the user: revokes active sessions and blocks new logins (reversible)."""
58
+ await self.post_json(f"users/{user_id}/lock")
59
+
60
+ async def unlock_user(self, user_id: str) -> None:
61
+ await self.post_json(f"users/{user_id}/unlock")
62
+
63
+ async def deactivate_user(self, user_id: str) -> None:
64
+ """Deactivate the user: revokes sessions and permanently disables the account."""
65
+ await self.post_json(f"users/{user_id}/deactivate")