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/matrix.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Matrix Client-Server API client (async) + the concrete :class:`MatrixEffectors`.
|
|
2
|
+
|
|
3
|
+
Phase 4. Drives the CS API over the shared async :class:`BaseApiClient` (AD-7) — *not* a Matrix
|
|
4
|
+
library: the library decision (``matrix-nio`` vs. raw httpx vs. ``mautrix``) is an explicit Phase 6
|
|
5
|
+
ADR (BATTLE_PLAN §5). Building on the base client keeps everything async, pooled and uniformly
|
|
6
|
+
authenticated, and lets the reconciler's :class:`~onbot.reconciler.effectors.MatrixEffectors` seam
|
|
7
|
+
get its concrete implementation here (the Phase 3 deferral).
|
|
8
|
+
|
|
9
|
+
Two things live here:
|
|
10
|
+
|
|
11
|
+
* :class:`ApiClientMatrix` — the CS-API operations the bot needs (room/space creation, kicks, power
|
|
12
|
+
levels, room name/topic, custom state events, sending messages, account data, and the sync
|
|
13
|
+
stream).
|
|
14
|
+
* :class:`CSApiEffectors` — adapts that client to the reconciler's ``MatrixEffectors`` protocol,
|
|
15
|
+
replacing the dry-run effectors when the bot runs for real.
|
|
16
|
+
|
|
17
|
+
The sync transport is **Simplified Sliding Sync** (MSC4186, AD-3). Its endpoint is still unstable;
|
|
18
|
+
Phase 6 adds CS-API version negotiation. To keep that churn out of the onboarding listener, the
|
|
19
|
+
response is normalised to :class:`SyncResult` here, so the listener consumes membership events
|
|
20
|
+
without knowing the wire shape.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import uuid
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any
|
|
29
|
+
from urllib.parse import quote
|
|
30
|
+
|
|
31
|
+
from onbot.auth.token_provider import TokenProvider
|
|
32
|
+
from onbot.clients.base import ApiError, BaseApiClient
|
|
33
|
+
from onbot.clients.versions import CLIENT_VERSIONS_PATH, ServerVersions
|
|
34
|
+
from onbot.logging import get_logger
|
|
35
|
+
from onbot.models import RoomCreateAttributes
|
|
36
|
+
|
|
37
|
+
log = get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
# MSC4186 Simplified Sliding Sync. Unstable path — gated by version negotiation (Phase 6).
|
|
40
|
+
SLIDING_SYNC_PATH = "unstable/org.matrix.simplified_msc3575/sync"
|
|
41
|
+
|
|
42
|
+
ENCRYPTION_ALGORITHM = "m.megolm.v1.aes-sha2"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SyncNotSupportedError(RuntimeError):
|
|
46
|
+
"""The homeserver does not advertise Simplified Sliding Sync (MSC4186)."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class RoomSync:
|
|
51
|
+
"""Normalised per-room slice of a sync response."""
|
|
52
|
+
|
|
53
|
+
room_id: str
|
|
54
|
+
timeline: list[dict[str, Any]] = field(default_factory=list)
|
|
55
|
+
required_state: list[dict[str, Any]] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
def member_events(self) -> list[dict[str, Any]]:
|
|
58
|
+
return [ev for ev in (*self.required_state, *self.timeline) if ev.get("type") == "m.room.member"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True)
|
|
62
|
+
class SyncResult:
|
|
63
|
+
"""Normalised sync response: the next stream position plus changed rooms."""
|
|
64
|
+
|
|
65
|
+
pos: str | None
|
|
66
|
+
rooms: list[RoomSync] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ApiClientMatrix(BaseApiClient):
|
|
70
|
+
"""Matrix CS-API operations the bot performs as the configured bot user."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
server_url: str,
|
|
75
|
+
access_token: str | None = None,
|
|
76
|
+
*,
|
|
77
|
+
server_name: str,
|
|
78
|
+
token_provider: TokenProvider | None = None,
|
|
79
|
+
**kwargs: Any,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.server_url = server_url.rstrip("/")
|
|
82
|
+
base = f"{self.server_url}/_matrix/client"
|
|
83
|
+
# Sliding sync long-polls; allow a generous read timeout unless the caller overrides it.
|
|
84
|
+
kwargs.setdefault("timeout", 60.0)
|
|
85
|
+
super().__init__(base_url=base, auth_token=access_token, token_provider=token_provider, **kwargs)
|
|
86
|
+
self.server_name = server_name
|
|
87
|
+
self._versions: ServerVersions | None = None
|
|
88
|
+
|
|
89
|
+
# --- version negotiation (Phase 6) --------------------------------------
|
|
90
|
+
|
|
91
|
+
async def negotiate_versions(self) -> ServerVersions:
|
|
92
|
+
"""Fetch + cache ``GET /_matrix/client/versions`` (the bot's capability source of truth)."""
|
|
93
|
+
payload = await self.get_json(CLIENT_VERSIONS_PATH)
|
|
94
|
+
self._versions = ServerVersions.from_payload(payload)
|
|
95
|
+
log.info(
|
|
96
|
+
"negotiated CS-API: sliding_sync=%s authenticated_media=%s",
|
|
97
|
+
self._versions.supports_simplified_sliding_sync(),
|
|
98
|
+
self._versions.supports_authenticated_media(),
|
|
99
|
+
)
|
|
100
|
+
return self._versions
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def versions(self) -> ServerVersions | None:
|
|
104
|
+
"""Cached negotiation result, or ``None`` until :meth:`negotiate_versions` has run."""
|
|
105
|
+
return self._versions
|
|
106
|
+
|
|
107
|
+
# --- device registration (MAS compatibility) ----------------------------
|
|
108
|
+
|
|
109
|
+
async def ensure_device_registered(self) -> None:
|
|
110
|
+
"""Register the bot's device so it can send messages under MAS.
|
|
111
|
+
|
|
112
|
+
A MAS-issued token (a ``mas-cli`` compatibility token or a client login) carries a device
|
|
113
|
+
id, but Synapse only persists a ``devices`` row once the client uploads device keys. Until
|
|
114
|
+
then, *sending a message* — which records the transaction id per device — fails with a
|
|
115
|
+
foreign-key 500 (``event_txn_id_device_id``). State-only writes (room create, power levels,
|
|
116
|
+
account data) are unaffected, which is why this only bites the welcome DM flow.
|
|
117
|
+
|
|
118
|
+
The bot advertises **no** encryption algorithms (ADR-0009: it operates outside encrypted
|
|
119
|
+
rooms), so this is purely the device-bookkeeping registration, not an e2ee opt-in. Verified
|
|
120
|
+
against the Phase 7b live stack; idempotent. Best-effort: never blocks startup.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
whoami = await self.get_json("v3/account/whoami")
|
|
124
|
+
device_id = whoami.get("device_id")
|
|
125
|
+
if not device_id:
|
|
126
|
+
return
|
|
127
|
+
await self.post_json(
|
|
128
|
+
"v3/keys/upload",
|
|
129
|
+
json_body={
|
|
130
|
+
"device_keys": {
|
|
131
|
+
"user_id": whoami["user_id"],
|
|
132
|
+
"device_id": device_id,
|
|
133
|
+
"algorithms": [],
|
|
134
|
+
"keys": {},
|
|
135
|
+
"signatures": {},
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
log.info("registered bot device %s for message sending (MAS compatibility)", device_id)
|
|
140
|
+
except Exception:
|
|
141
|
+
log.exception("could not register bot device; welcome DM sends may fail under MAS")
|
|
142
|
+
|
|
143
|
+
# --- room / space creation ----------------------------------------------
|
|
144
|
+
|
|
145
|
+
async def create_room(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
alias_localpart: str | None = None,
|
|
149
|
+
name: str | None = None,
|
|
150
|
+
topic: str | None = None,
|
|
151
|
+
encrypted: bool = False,
|
|
152
|
+
room_params: Mapping[str, Any] | None = None,
|
|
153
|
+
parent_space_id: str | None = None,
|
|
154
|
+
is_direct: bool = False,
|
|
155
|
+
invite: list[str] | None = None,
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Create a room (https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3createroom).
|
|
158
|
+
|
|
159
|
+
Returns the new ``room_id``. ``room_params`` (preset/visibility/federation/…) are merged in
|
|
160
|
+
verbatim; encryption and the space-parent link are added as ``initial_state`` events.
|
|
161
|
+
"""
|
|
162
|
+
initial_state: list[dict[str, Any]] = []
|
|
163
|
+
if parent_space_id:
|
|
164
|
+
# https://spec.matrix.org/latest/client-server-api/#mspaceparent
|
|
165
|
+
initial_state.append(
|
|
166
|
+
{
|
|
167
|
+
"type": "m.space.parent",
|
|
168
|
+
"state_key": parent_space_id,
|
|
169
|
+
"content": {"canonical": True, "via": [self.server_name]},
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
if encrypted:
|
|
173
|
+
initial_state.append(
|
|
174
|
+
{"type": "m.room.encryption", "content": {"algorithm": ENCRYPTION_ALGORITHM}}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
body: dict[str, Any] = {**(room_params or {})}
|
|
178
|
+
if alias_localpart:
|
|
179
|
+
body["room_alias_name"] = alias_localpart
|
|
180
|
+
if name is not None:
|
|
181
|
+
body["name"] = name
|
|
182
|
+
if topic is not None:
|
|
183
|
+
body["topic"] = topic
|
|
184
|
+
if is_direct:
|
|
185
|
+
body["is_direct"] = True
|
|
186
|
+
if invite:
|
|
187
|
+
body["invite"] = invite
|
|
188
|
+
if initial_state:
|
|
189
|
+
body["initial_state"] = initial_state
|
|
190
|
+
|
|
191
|
+
result = await self.post_json("v3/createRoom", json_body=body)
|
|
192
|
+
room_id: str = result["room_id"]
|
|
193
|
+
if parent_space_id:
|
|
194
|
+
# https://spec.matrix.org/latest/client-server-api/#mspacechild
|
|
195
|
+
await self.put_room_state_event(
|
|
196
|
+
parent_space_id,
|
|
197
|
+
"m.space.child",
|
|
198
|
+
{"suggested": True, "via": [self.server_name]},
|
|
199
|
+
state_key=room_id,
|
|
200
|
+
)
|
|
201
|
+
return room_id
|
|
202
|
+
|
|
203
|
+
async def create_space(
|
|
204
|
+
self, *, alias_localpart: str, name: str, topic: str, params: Mapping[str, Any]
|
|
205
|
+
) -> str:
|
|
206
|
+
body: dict[str, Any] = {
|
|
207
|
+
"creation_content": {"type": "m.space"},
|
|
208
|
+
"room_alias_name": alias_localpart,
|
|
209
|
+
"name": name,
|
|
210
|
+
"topic": topic,
|
|
211
|
+
**params,
|
|
212
|
+
}
|
|
213
|
+
result = await self.post_json("v3/createRoom", json_body=body)
|
|
214
|
+
room_id: str = result["room_id"]
|
|
215
|
+
return room_id
|
|
216
|
+
|
|
217
|
+
async def create_direct_message_room(self, user_id: str) -> str:
|
|
218
|
+
"""Create a 1:1 invite-only DM room with ``user_id`` (G4.1)."""
|
|
219
|
+
return await self.create_room(
|
|
220
|
+
is_direct=True,
|
|
221
|
+
invite=[user_id],
|
|
222
|
+
room_params={"preset": "trusted_private_chat"},
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async def resolve_room_alias(self, alias: str) -> str | None:
|
|
226
|
+
"""Resolve a room alias (``#name:server``) to its ``room_id``, or ``None`` if unknown."""
|
|
227
|
+
# https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3directoryroomroomalias
|
|
228
|
+
# The alias (e.g. "#room:server") must be percent-encoded — notably "#" and ":".
|
|
229
|
+
try:
|
|
230
|
+
result = await self.get_json(f"v3/directory/room/{quote(alias, safe='')}")
|
|
231
|
+
except ApiError as exc:
|
|
232
|
+
if exc.status_code == 404:
|
|
233
|
+
return None
|
|
234
|
+
raise
|
|
235
|
+
room_id: str = result["room_id"]
|
|
236
|
+
return room_id
|
|
237
|
+
|
|
238
|
+
async def link_room_to_space(self, space_id: str, room_id: str, *, suggested: bool = False) -> None:
|
|
239
|
+
"""Add ``room_id`` to ``space_id`` (m.space.child on the space + m.space.parent on the room)."""
|
|
240
|
+
# https://spec.matrix.org/latest/client-server-api/#mspacechild
|
|
241
|
+
await self.put_room_state_event(
|
|
242
|
+
space_id,
|
|
243
|
+
"m.space.child",
|
|
244
|
+
{"suggested": suggested, "via": [self.server_name]},
|
|
245
|
+
state_key=room_id,
|
|
246
|
+
)
|
|
247
|
+
await self.put_room_state_event(
|
|
248
|
+
room_id,
|
|
249
|
+
"m.space.parent",
|
|
250
|
+
{"canonical": True, "via": [self.server_name]},
|
|
251
|
+
state_key=space_id,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# --- membership ----------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
async def kick_user(self, room_id: str, user_id: str, reason: str | None = None) -> None:
|
|
257
|
+
# https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3roomsroomidkick
|
|
258
|
+
body: dict[str, Any] = {"user_id": user_id}
|
|
259
|
+
if reason:
|
|
260
|
+
body["reason"] = reason
|
|
261
|
+
await self.post_json(f"v3/rooms/{room_id}/kick", json_body=body)
|
|
262
|
+
|
|
263
|
+
# --- room state ----------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
async def get_room_state_event(
|
|
266
|
+
self, room_id: str, event_type: str, state_key: str = ""
|
|
267
|
+
) -> dict[str, Any] | None:
|
|
268
|
+
"""Fetch a single state event's content, or ``None`` if it is not set (HTTP 404).
|
|
269
|
+
|
|
270
|
+
Targets the event directly (no full-state linear scan — the legacy §3 inefficiency).
|
|
271
|
+
"""
|
|
272
|
+
path = f"v3/rooms/{room_id}/state/{event_type}"
|
|
273
|
+
if state_key:
|
|
274
|
+
path = f"{path}/{state_key}"
|
|
275
|
+
try:
|
|
276
|
+
content: dict[str, Any] = await self.get_json(path)
|
|
277
|
+
except ApiError as exc:
|
|
278
|
+
if exc.status_code == 404:
|
|
279
|
+
return None
|
|
280
|
+
raise
|
|
281
|
+
return content
|
|
282
|
+
|
|
283
|
+
async def put_room_state_event(
|
|
284
|
+
self, room_id: str, event_type: str, content: Mapping[str, Any], state_key: str = ""
|
|
285
|
+
) -> None:
|
|
286
|
+
path = f"v3/rooms/{room_id}/state/{event_type}"
|
|
287
|
+
if state_key:
|
|
288
|
+
path = f"{path}/{state_key}"
|
|
289
|
+
await self.put_json(path, json_body=dict(content))
|
|
290
|
+
|
|
291
|
+
async def get_room_power_levels(self, room_id: str) -> dict[str, Any]:
|
|
292
|
+
return await self.get_room_state_event(room_id, "m.room.power_levels") or {}
|
|
293
|
+
|
|
294
|
+
async def set_room_power_levels(self, room_id: str, power_levels: Mapping[str, Any]) -> None:
|
|
295
|
+
await self.put_room_state_event(room_id, "m.room.power_levels", power_levels)
|
|
296
|
+
|
|
297
|
+
async def set_room_name(self, room_id: str, name: str) -> None:
|
|
298
|
+
await self.put_room_state_event(room_id, "m.room.name", {"name": name})
|
|
299
|
+
|
|
300
|
+
async def set_room_topic(self, room_id: str, topic: str) -> None:
|
|
301
|
+
await self.put_room_state_event(room_id, "m.room.topic", {"topic": topic})
|
|
302
|
+
|
|
303
|
+
async def set_room_avatar(self, room_id: str, mxc_uri: str) -> None:
|
|
304
|
+
# https://spec.matrix.org/latest/client-server-api/#mroomavatar
|
|
305
|
+
await self.put_room_state_event(room_id, "m.room.avatar", {"url": mxc_uri})
|
|
306
|
+
|
|
307
|
+
async def set_user_avatar(self, user_id: str, mxc_uri: str) -> None:
|
|
308
|
+
# https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3profileuseridavatar_url
|
|
309
|
+
await self.put_json(f"v3/profile/{user_id}/avatar_url", json_body={"avatar_url": mxc_uri})
|
|
310
|
+
|
|
311
|
+
# --- media (authenticated, MSC3916) --------------------------------------
|
|
312
|
+
|
|
313
|
+
async def upload_media(self, content: bytes, *, content_type: str, filename: str | None = None) -> str:
|
|
314
|
+
"""Upload bytes to the media repo, returning the ``mxc://`` URI (G10.1).
|
|
315
|
+
|
|
316
|
+
Uploads are authenticated by the bot's token; the resulting ``mxc`` is later fetched via the
|
|
317
|
+
authenticated download endpoint below.
|
|
318
|
+
https://spec.matrix.org/latest/client-server-api/#post_matrixmediav3upload
|
|
319
|
+
"""
|
|
320
|
+
params = {"filename": filename} if filename else None
|
|
321
|
+
result = await self.request_raw(
|
|
322
|
+
"POST",
|
|
323
|
+
f"{self.server_url}/_matrix/media/v3/upload",
|
|
324
|
+
params=params,
|
|
325
|
+
content=content,
|
|
326
|
+
headers={"Content-Type": content_type},
|
|
327
|
+
)
|
|
328
|
+
content_uri: str = result["content_uri"]
|
|
329
|
+
return content_uri
|
|
330
|
+
|
|
331
|
+
async def download_media(self, mxc_uri: str) -> bytes:
|
|
332
|
+
"""Download media by ``mxc://`` URI over the **authenticated** endpoint (MSC3916).
|
|
333
|
+
|
|
334
|
+
https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediadownloadservernamemediaid
|
|
335
|
+
"""
|
|
336
|
+
server, media_id = _parse_mxc(mxc_uri)
|
|
337
|
+
data: bytes = await self.request_raw(
|
|
338
|
+
"GET", f"v1/media/download/{server}/{media_id}", parse_json=False
|
|
339
|
+
)
|
|
340
|
+
return data
|
|
341
|
+
|
|
342
|
+
# --- messaging -----------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
async def send_text_message(self, room_id: str, body: str) -> str:
|
|
345
|
+
"""Send a plain-text message; returns the event id. Uses a unique transaction id."""
|
|
346
|
+
txn = uuid.uuid4().hex
|
|
347
|
+
result = await self.put_json(
|
|
348
|
+
f"v3/rooms/{room_id}/send/m.room.message/{txn}",
|
|
349
|
+
json_body={"msgtype": "m.text", "body": body},
|
|
350
|
+
)
|
|
351
|
+
event_id: str = result["event_id"]
|
|
352
|
+
return event_id
|
|
353
|
+
|
|
354
|
+
# --- account data --------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
async def get_account_data(self, user_id: str, data_type: str) -> dict[str, Any]:
|
|
357
|
+
try:
|
|
358
|
+
data: dict[str, Any] = await self.get_json(f"v3/user/{user_id}/account_data/{data_type}")
|
|
359
|
+
except ApiError as exc:
|
|
360
|
+
if exc.status_code == 404:
|
|
361
|
+
return {}
|
|
362
|
+
raise
|
|
363
|
+
return data
|
|
364
|
+
|
|
365
|
+
async def set_account_data(self, user_id: str, data_type: str, content: Mapping[str, Any]) -> None:
|
|
366
|
+
await self.put_json(f"v3/user/{user_id}/account_data/{data_type}", json_body=dict(content))
|
|
367
|
+
|
|
368
|
+
# --- sync stream (Simplified Sliding Sync, MSC4186) ----------------------
|
|
369
|
+
|
|
370
|
+
async def sliding_sync(self, pos: str | None = None, *, timeout_ms: int = 30000) -> SyncResult:
|
|
371
|
+
"""One Simplified Sliding Sync round-trip, normalised to :class:`SyncResult`.
|
|
372
|
+
|
|
373
|
+
Long-polls server-side for up to ``timeout_ms``; pass the returned ``pos`` back to continue
|
|
374
|
+
the stream. Subscribes to all rooms and asks for member state so the listener can react to
|
|
375
|
+
joins (AD-3). The wire shape is unstable (Phase 6 negotiation) — kept behind this method.
|
|
376
|
+
|
|
377
|
+
Raises :class:`SyncNotSupportedError` if version negotiation ran and the server does not
|
|
378
|
+
advertise Simplified Sliding Sync, so the listener can fall back to the signal-only path.
|
|
379
|
+
"""
|
|
380
|
+
if self._versions is not None and not self._versions.supports_simplified_sliding_sync():
|
|
381
|
+
raise SyncNotSupportedError(
|
|
382
|
+
"homeserver does not advertise org.matrix.simplified_msc3575 (MSC4186)"
|
|
383
|
+
)
|
|
384
|
+
params: dict[str, Any] = {"timeout": timeout_ms}
|
|
385
|
+
if pos:
|
|
386
|
+
params["pos"] = pos
|
|
387
|
+
body = {
|
|
388
|
+
"lists": {
|
|
389
|
+
"onbot": {
|
|
390
|
+
"ranges": [[0, 1000]],
|
|
391
|
+
"required_state": [["m.room.member", "*"]],
|
|
392
|
+
"timeline_limit": 50,
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
data = await self.request_json("POST", SLIDING_SYNC_PATH, params=params, json_body=body)
|
|
397
|
+
data = data or {}
|
|
398
|
+
rooms = [
|
|
399
|
+
RoomSync(
|
|
400
|
+
room_id=room_id,
|
|
401
|
+
timeline=list((room or {}).get("timeline", [])),
|
|
402
|
+
required_state=list((room or {}).get("required_state", [])),
|
|
403
|
+
)
|
|
404
|
+
for room_id, room in (data.get("rooms") or {}).items()
|
|
405
|
+
]
|
|
406
|
+
return SyncResult(pos=data.get("pos"), rooms=rooms)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _parse_mxc(mxc_uri: str) -> tuple[str, str]:
|
|
410
|
+
"""Split ``mxc://server/media_id`` into ``(server, media_id)``."""
|
|
411
|
+
if not mxc_uri.startswith("mxc://"):
|
|
412
|
+
raise ValueError(f"not an mxc URI: {mxc_uri!r}")
|
|
413
|
+
server, _, media_id = mxc_uri.removeprefix("mxc://").partition("/")
|
|
414
|
+
if not server or not media_id:
|
|
415
|
+
raise ValueError(f"malformed mxc URI: {mxc_uri!r}")
|
|
416
|
+
return server, media_id
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class CSApiEffectors:
|
|
420
|
+
"""Concrete :class:`~onbot.reconciler.effectors.MatrixEffectors` backed by the CS API.
|
|
421
|
+
|
|
422
|
+
Replaces ``DryRunEffectors`` when the bot runs for real (Phase 3 deferral resolved).
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
def __init__(self, client: ApiClientMatrix) -> None:
|
|
426
|
+
self.client = client
|
|
427
|
+
|
|
428
|
+
async def create_group_room(self, attrs: RoomCreateAttributes, parent_space_id: str | None) -> str:
|
|
429
|
+
return await self.client.create_room(
|
|
430
|
+
alias_localpart=attrs.alias,
|
|
431
|
+
name=attrs.name,
|
|
432
|
+
topic=attrs.topic,
|
|
433
|
+
encrypted=attrs.encrypted,
|
|
434
|
+
room_params=attrs.room_params,
|
|
435
|
+
parent_space_id=parent_space_id,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
async def create_space(self, *, alias: str, name: str, topic: str, params: dict[str, Any]) -> str:
|
|
439
|
+
return await self.client.create_space(alias_localpart=alias, name=name, topic=topic, params=params)
|
|
440
|
+
|
|
441
|
+
async def kick_user(self, room_id: str, user_id: str, reason: str | None = None) -> None:
|
|
442
|
+
await self.client.kick_user(room_id, user_id, reason)
|
|
443
|
+
|
|
444
|
+
async def get_room_power_levels(self, room_id: str) -> dict[str, Any]:
|
|
445
|
+
return await self.client.get_room_power_levels(room_id)
|
|
446
|
+
|
|
447
|
+
async def set_room_power_levels(self, room_id: str, power_levels: dict[str, Any]) -> None:
|
|
448
|
+
await self.client.set_room_power_levels(room_id, power_levels)
|
|
449
|
+
|
|
450
|
+
async def set_room_name(self, room_id: str, name: str) -> None:
|
|
451
|
+
await self.client.set_room_name(room_id, name)
|
|
452
|
+
|
|
453
|
+
async def set_room_topic(self, room_id: str, topic: str) -> None:
|
|
454
|
+
await self.client.set_room_topic(room_id, topic)
|
|
455
|
+
|
|
456
|
+
async def put_room_state(self, room_id: str, event_type: str, content: dict[str, Any]) -> None:
|
|
457
|
+
await self.client.put_room_state_event(room_id, event_type, content)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Synapse Admin API client (async, paginated).
|
|
2
|
+
|
|
3
|
+
Ported from legacy ``onbot/api_client_synapse_admin.py`` onto the async :class:`BaseApiClient`,
|
|
4
|
+
fixing the BATTLE_PLAN §3 bugs:
|
|
5
|
+
|
|
6
|
+
* full pagination on users/rooms/media (legacy read only page 1, ``limit=None`` was a no-op);
|
|
7
|
+
* ``delete_room`` now actually sends its body (legacy built it then discarded it);
|
|
8
|
+
* ``set_room_admin`` / ``set_user_server_admin_state`` call the correct endpoints (legacy did a
|
|
9
|
+
stray GET / raised ``NotImplementedError``);
|
|
10
|
+
* ``room_is_blocked`` returns a plain bool instead of string-matching.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from onbot.auth.token_provider import TokenProvider
|
|
19
|
+
from onbot.clients.base import BaseApiClient
|
|
20
|
+
from onbot.logging import get_logger
|
|
21
|
+
|
|
22
|
+
log = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
_DEFAULT_PAGE_SIZE = 100
|
|
25
|
+
|
|
26
|
+
NextParams = Callable[[Any, dict[str, Any]], dict[str, Any] | None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _next_token_params(token_key: str) -> NextParams:
|
|
30
|
+
"""Build a ``next_params`` callback for endpoints that page via an opaque ``next_token``."""
|
|
31
|
+
|
|
32
|
+
def _next(page: Any, current: dict[str, Any]) -> dict[str, Any] | None:
|
|
33
|
+
token = (page or {}).get(token_key)
|
|
34
|
+
if token is None:
|
|
35
|
+
return None
|
|
36
|
+
return {**current, "from": token}
|
|
37
|
+
|
|
38
|
+
return _next
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ApiClientSynapseAdmin(BaseApiClient):
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
server_url: str,
|
|
45
|
+
access_token: str | None = None,
|
|
46
|
+
*,
|
|
47
|
+
admin_api_path: str = "_synapse/admin",
|
|
48
|
+
token_provider: TokenProvider | None = None,
|
|
49
|
+
**kwargs: Any,
|
|
50
|
+
) -> None:
|
|
51
|
+
base = f"{server_url.rstrip('/')}/{admin_api_path.strip('/')}"
|
|
52
|
+
super().__init__(base_url=base, auth_token=access_token, token_provider=token_provider, **kwargs)
|
|
53
|
+
|
|
54
|
+
# --- reads (paginated) ---------------------------------------------------
|
|
55
|
+
|
|
56
|
+
async def list_users(self) -> list[dict[str, Any]]:
|
|
57
|
+
# https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#list-accounts
|
|
58
|
+
# ``guests=false`` is required under MAS/MSC3861 (Synapse rejects the endpoint's default
|
|
59
|
+
# ``guests`` handling with M_INVALID_PARAM) and is harmless otherwise — there are no guest
|
|
60
|
+
# accounts in the MAS topology (ADR-0006). Verified by the Phase 7b integration harness.
|
|
61
|
+
return await self.paginate_collect(
|
|
62
|
+
"v2/users",
|
|
63
|
+
params={"limit": _DEFAULT_PAGE_SIZE, "guests": "false"},
|
|
64
|
+
extract_items=lambda page: page["users"],
|
|
65
|
+
next_params=_next_token_params("next_token"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def list_rooms(self, *, search_term: str | None = None) -> list[dict[str, Any]]:
|
|
69
|
+
# https://element-hq.github.io/synapse/latest/admin_api/rooms.html#list-room-api
|
|
70
|
+
return await self.paginate_collect(
|
|
71
|
+
"v1/rooms",
|
|
72
|
+
params={"limit": _DEFAULT_PAGE_SIZE, "search_term": search_term},
|
|
73
|
+
extract_items=lambda page: page["rooms"],
|
|
74
|
+
next_params=lambda page, cur: (
|
|
75
|
+
{**cur, "from": page["next_batch"]} if page.get("next_batch") is not None else None
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def list_non_space_rooms(self, *, search_term: str | None = None) -> list[dict[str, Any]]:
|
|
80
|
+
return [r for r in await self.list_rooms(search_term=search_term) if r.get("room_type") != "m.space"]
|
|
81
|
+
|
|
82
|
+
async def list_spaces(self) -> list[dict[str, Any]]:
|
|
83
|
+
return [r for r in await self.list_rooms() if r.get("room_type") == "m.space"]
|
|
84
|
+
|
|
85
|
+
async def list_room_members(self, room_id: str) -> list[str]:
|
|
86
|
+
# https://element-hq.github.io/synapse/latest/admin_api/rooms.html#room-members-api
|
|
87
|
+
result = await self.get_json(f"v1/rooms/{room_id}/members")
|
|
88
|
+
members: list[str] = result["members"]
|
|
89
|
+
return members
|
|
90
|
+
|
|
91
|
+
async def get_room_details(self, room_id: str) -> dict[str, Any]:
|
|
92
|
+
# https://element-hq.github.io/synapse/latest/admin_api/rooms.html#room-details-api
|
|
93
|
+
details: dict[str, Any] = await self.get_json(f"v1/rooms/{room_id}")
|
|
94
|
+
return details
|
|
95
|
+
|
|
96
|
+
async def list_user_media(self, user_id: str) -> list[dict[str, Any]]:
|
|
97
|
+
# https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#list-media-uploaded-by-a-user
|
|
98
|
+
return await self.paginate_collect(
|
|
99
|
+
f"v1/users/{user_id}/media",
|
|
100
|
+
params={"limit": _DEFAULT_PAGE_SIZE},
|
|
101
|
+
extract_items=lambda page: page["media"],
|
|
102
|
+
next_params=_next_token_params("next_token"),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# --- room membership / state --------------------------------------------
|
|
106
|
+
|
|
107
|
+
async def add_user_to_room(self, room_id: str, user_id: str) -> None:
|
|
108
|
+
# https://element-hq.github.io/synapse/latest/admin_api/room_membership.html
|
|
109
|
+
await self.post_json(f"v1/join/{room_id}", json_body={"user_id": user_id})
|
|
110
|
+
|
|
111
|
+
async def make_room_admin(self, room_id: str, user_id: str) -> None:
|
|
112
|
+
# https://element-hq.github.io/synapse/latest/admin_api/rooms.html#make-room-admin-api
|
|
113
|
+
await self.post_json(f"v1/rooms/{room_id}/make_room_admin", json_body={"user_id": user_id})
|
|
114
|
+
|
|
115
|
+
async def set_user_server_admin_state(self, user_id: str, *, admin: bool) -> None:
|
|
116
|
+
# https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#change-whether-a-user-is-a-server-administrator-or-not
|
|
117
|
+
await self.put_json(f"v1/users/{user_id}/admin", json_body={"admin": admin})
|
|
118
|
+
|
|
119
|
+
async def room_is_blocked(self, room_id: str) -> bool:
|
|
120
|
+
# https://element-hq.github.io/synapse/latest/admin_api/rooms.html#get-block-status
|
|
121
|
+
result = await self.get_json(f"v1/rooms/{room_id}/block")
|
|
122
|
+
return bool(result.get("block"))
|
|
123
|
+
|
|
124
|
+
async def room_set_blocked(self, room_id: str, *, blocked: bool) -> None:
|
|
125
|
+
# https://element-hq.github.io/synapse/latest/admin_api/rooms.html#block-or-unblock-a-room
|
|
126
|
+
await self.put_json(f"v1/rooms/{room_id}/block", json_body={"block": blocked})
|
|
127
|
+
|
|
128
|
+
# --- lifecycle (used by Phase 5; correct now to avoid carrying broken stubs) ---
|
|
129
|
+
|
|
130
|
+
async def deactivate_account(self, user_id: str, *, erase: bool) -> None:
|
|
131
|
+
# https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#deactivate-account
|
|
132
|
+
await self.post_json(f"v1/deactivate/{user_id}", json_body={"erase": erase})
|
|
133
|
+
|
|
134
|
+
async def logout_account(self, user_id: str) -> None:
|
|
135
|
+
# Revoke all sessions by deleting every device.
|
|
136
|
+
devices = (await self.get_json(f"v2/users/{user_id}/devices"))["devices"]
|
|
137
|
+
for device in devices:
|
|
138
|
+
await self.delete_json(f"v2/users/{user_id}/devices/{device['device_id']}")
|
|
139
|
+
|
|
140
|
+
async def delete_user_media(self, user_id: str) -> dict[str, Any]:
|
|
141
|
+
# https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#delete-media-uploaded-by-a-user
|
|
142
|
+
result: dict[str, Any] = await self.delete_json(f"v1/users/{user_id}/media")
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
async def delete_room(
|
|
146
|
+
self,
|
|
147
|
+
room_id: str,
|
|
148
|
+
*,
|
|
149
|
+
block: bool = False,
|
|
150
|
+
purge: bool = True,
|
|
151
|
+
force_purge: bool = False,
|
|
152
|
+
message: str | None = None,
|
|
153
|
+
) -> dict[str, Any]:
|
|
154
|
+
# https://element-hq.github.io/synapse/latest/admin_api/rooms.html#delete-room-api
|
|
155
|
+
body: dict[str, Any] = {"block": block, "purge": purge, "force_purge": force_purge}
|
|
156
|
+
if message:
|
|
157
|
+
body["message"] = message
|
|
158
|
+
result: dict[str, Any] = await self.delete_json(f"v1/rooms/{room_id}", json_body=body)
|
|
159
|
+
return result
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Centralized Matrix CS-API version + feature knowledge (Phase 6).
|
|
2
|
+
|
|
3
|
+
The Matrix wire protocol moves under us — endpoints get promoted from ``unstable`` to versioned,
|
|
4
|
+
and capabilities like authenticated media or Simplified Sliding Sync only exist past a certain spec
|
|
5
|
+
version. Rather than sprinkle version strings and unstable-feature flags across the clients, the bot
|
|
6
|
+
negotiates once against ``GET /_matrix/client/versions`` and answers capability questions from the
|
|
7
|
+
result (:class:`ServerVersions`). This is the single source of truth for "can the server do X".
|
|
8
|
+
|
|
9
|
+
References:
|
|
10
|
+
* https://spec.matrix.org/latest/client-server-api/#get_matrixclientversions
|
|
11
|
+
* Authenticated media (MSC3916) is stable since spec **v1.11**.
|
|
12
|
+
* Simplified Sliding Sync (MSC4186) is still unstable: ``org.matrix.simplified_msc3575``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
# GET path, relative to the ApiClientMatrix base (".../_matrix/client").
|
|
20
|
+
CLIENT_VERSIONS_PATH = "versions"
|
|
21
|
+
|
|
22
|
+
# Unstable feature flag advertising Simplified Sliding Sync support (MSC4186 / MSC3575).
|
|
23
|
+
SIMPLIFIED_SLIDING_SYNC_FEATURE = "org.matrix.simplified_msc3575"
|
|
24
|
+
|
|
25
|
+
# First spec version in which authenticated media (MSC3916) is stable.
|
|
26
|
+
AUTHENTICATED_MEDIA_SINCE = "v1.11"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _version_tuple(version: str) -> tuple[int, ...]:
|
|
30
|
+
"""Parse a spec version like ``"v1.11"`` into ``(1, 11)`` for ordering; ``()`` if unparsable."""
|
|
31
|
+
try:
|
|
32
|
+
return tuple(int(part) for part in version.removeprefix("v").split("."))
|
|
33
|
+
except ValueError:
|
|
34
|
+
return ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class ServerVersions:
|
|
39
|
+
"""Parsed ``/versions`` response, answering capability questions for the rest of the bot."""
|
|
40
|
+
|
|
41
|
+
versions: list[str] = field(default_factory=list)
|
|
42
|
+
unstable_features: dict[str, bool] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_payload(cls, payload: dict[str, object] | None) -> ServerVersions:
|
|
46
|
+
payload = payload or {}
|
|
47
|
+
versions = payload.get("versions")
|
|
48
|
+
features = payload.get("unstable_features")
|
|
49
|
+
return cls(
|
|
50
|
+
versions=[v for v in versions if isinstance(v, str)] if isinstance(versions, list) else [],
|
|
51
|
+
unstable_features=(
|
|
52
|
+
{k: bool(v) for k, v in features.items()} if isinstance(features, dict) else {}
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def supports_simplified_sliding_sync(self) -> bool:
|
|
57
|
+
return self.unstable_features.get(SIMPLIFIED_SLIDING_SYNC_FEATURE, False)
|
|
58
|
+
|
|
59
|
+
def supports_authenticated_media(self) -> bool:
|
|
60
|
+
threshold = _version_tuple(AUTHENTICATED_MEDIA_SINCE)
|
|
61
|
+
return any(_version_tuple(v) >= threshold for v in self.versions if _version_tuple(v))
|