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/__init__.py +7 -0
- onbot/__main__.py +6 -0
- onbot/app.py +171 -0
- onbot/auth/__init__.py +19 -0
- onbot/auth/token_provider.py +153 -0
- onbot/cli.py +80 -0
- onbot/clients/__init__.py +5 -0
- onbot/clients/authentik.py +123 -0
- onbot/clients/base.py +243 -0
- onbot/clients/mas_admin.py +65 -0
- onbot/clients/matrix.py +457 -0
- onbot/clients/synapse_admin.py +159 -0
- onbot/clients/versions.py +61 -0
- onbot/config.py +653 -0
- onbot/events.py +51 -0
- onbot/healthcheck.py +134 -0
- onbot/identity.py +44 -0
- onbot/lifecycle/__init__.py +2 -0
- onbot/lifecycle/accounts.py +309 -0
- onbot/logging.py +26 -0
- onbot/media.py +46 -0
- onbot/models.py +74 -0
- onbot/onboarding/__init__.py +5 -0
- onbot/onboarding/listener.py +103 -0
- onbot/onboarding/welcome.py +116 -0
- onbot/reconciler/__init__.py +2 -0
- onbot/reconciler/effectors.py +78 -0
- onbot/reconciler/engine.py +348 -0
- onbot/reconciler/membership.py +54 -0
- onbot/reconciler/power_levels.py +96 -0
- onbot/reconciler/rooms.py +127 -0
- onbot/reconciler/state.py +77 -0
- onbot/utils.py +61 -0
- onbot-0.1.0.dev0.dist-info/METADATA +241 -0
- onbot-0.1.0.dev0.dist-info/RECORD +38 -0
- onbot-0.1.0.dev0.dist-info/WHEEL +4 -0
- onbot-0.1.0.dev0.dist-info/entry_points.txt +5 -0
- onbot-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
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")
|