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.
@@ -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))