390 lines
17 KiB
Python
390 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
import urllib.parse
|
|
|
|
import httpx
|
|
|
|
from ..utils.logging import get_logger
|
|
from .comms_protocol import (
|
|
HTTP_ACCEPTED,
|
|
HTTP_CONFLICT,
|
|
HTTP_CREATED,
|
|
HTTP_NOT_FOUND,
|
|
HTTP_OK,
|
|
_auth,
|
|
_canon_user,
|
|
)
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class _CommsRoomOpsMixin:
|
|
def run_pin_invite(self, wait: bool = True) -> dict[str, Any]:
|
|
if not self._settings.comms_seeder_password:
|
|
raise RuntimeError("comms seeder password missing")
|
|
|
|
with self._client() as client:
|
|
token = self._login(client, self._settings.comms_seeder_user, self._settings.comms_seeder_password)
|
|
room_id = self._resolve_alias(client, token, self._settings.comms_room_alias)
|
|
pinned = self._get_pinned(client, token, room_id)
|
|
for event_id in pinned:
|
|
event = self._get_event(client, token, room_id, event_id)
|
|
if event and (event.get("content") or {}).get("body") == self._settings.comms_pin_message:
|
|
return {"status": "ok", "detail": "already pinned"}
|
|
event_id = self._send_message(client, token, room_id, self._settings.comms_pin_message)
|
|
if not event_id:
|
|
return {"status": "error", "detail": "pin event_id missing"}
|
|
self._pin_message(client, token, room_id, event_id)
|
|
return {"status": "ok", "detail": "pinned"}
|
|
|
|
def run_reset_room(self, wait: bool = True) -> dict[str, Any]:
|
|
if not self._settings.comms_seeder_password:
|
|
raise RuntimeError("comms seeder password missing")
|
|
|
|
with self._client() as client:
|
|
token = self._login_with_retry(client, self._settings.comms_seeder_user, self._settings.comms_seeder_password)
|
|
old_room_id = self._resolve_alias(client, token, self._settings.comms_room_alias)
|
|
new_room_id = self._create_room(client, token, self._settings.comms_room_name)
|
|
self._set_room_state(client, token, new_room_id, "m.room.join_rules", {"join_rule": "public"})
|
|
self._set_room_state(client, token, new_room_id, "m.room.guest_access", {"guest_access": "can_join"})
|
|
self._set_room_state(
|
|
client,
|
|
token,
|
|
new_room_id,
|
|
"m.room.history_visibility",
|
|
{"history_visibility": "shared"},
|
|
)
|
|
self._set_room_state(client, token, new_room_id, "m.room.power_levels", self._power_levels())
|
|
|
|
self._delete_alias(client, token, self._settings.comms_room_alias)
|
|
self._put_alias(client, token, self._settings.comms_room_alias, new_room_id)
|
|
self._set_room_state(
|
|
client,
|
|
token,
|
|
new_room_id,
|
|
"m.room.canonical_alias",
|
|
{"alias": self._settings.comms_room_alias},
|
|
)
|
|
self._set_directory_visibility(client, token, new_room_id, "public")
|
|
|
|
bot_user_id = _canon_user(self._settings.comms_bot_user, self._settings.comms_server_name)
|
|
self._invite_user(client, token, new_room_id, bot_user_id)
|
|
for uid in self._list_joined_members(client, token, old_room_id):
|
|
if uid == _canon_user(self._settings.comms_seeder_user, self._settings.comms_server_name):
|
|
continue
|
|
localpart = uid.split(":", 1)[0].lstrip("@")
|
|
if localpart.isdigit():
|
|
continue
|
|
self._invite_user(client, token, new_room_id, uid)
|
|
|
|
event_id = self._send_message(client, token, new_room_id, self._settings.comms_pin_message)
|
|
if not event_id:
|
|
raise RuntimeError("pin message event_id missing")
|
|
self._set_room_state(client, token, new_room_id, "m.room.pinned_events", {"pinned": [event_id]})
|
|
|
|
self._set_directory_visibility(client, token, old_room_id, "private")
|
|
self._set_room_state(client, token, old_room_id, "m.room.join_rules", {"join_rule": "invite"})
|
|
self._set_room_state(client, token, old_room_id, "m.room.guest_access", {"guest_access": "forbidden"})
|
|
self._set_room_state(
|
|
client,
|
|
token,
|
|
old_room_id,
|
|
"m.room.tombstone",
|
|
{
|
|
"body": "Othrys has been reset. Please join the new room.",
|
|
"replacement_room": new_room_id,
|
|
},
|
|
)
|
|
self._send_message(
|
|
client,
|
|
token,
|
|
old_room_id,
|
|
"Othrys was reset. Join the new room at https://live.bstein.dev/#/room/#othrys:live.bstein.dev?action=join",
|
|
)
|
|
|
|
return {"status": "ok", "detail": f"old_room_id={old_room_id} new_room_id={new_room_id}"}
|
|
|
|
def run_seed_room(self, wait: bool = True) -> dict[str, Any]:
|
|
if not self._settings.comms_seeder_password or not self._settings.comms_bot_password:
|
|
raise RuntimeError("comms seeder/bot password missing")
|
|
|
|
with self._client() as client:
|
|
token = self._login(client, self._settings.comms_seeder_user, self._settings.comms_seeder_password)
|
|
for user, password, admin in (
|
|
(self._settings.comms_seeder_user, self._settings.comms_seeder_password, True),
|
|
(self._settings.comms_bot_user, self._settings.comms_bot_password, False),
|
|
):
|
|
try:
|
|
self._ensure_user(client, token, user, password, admin)
|
|
except RuntimeError as exc:
|
|
message = str(exc)
|
|
if "You are not a server admin" in message:
|
|
logger.warning(
|
|
"comms seed room ensure skipped",
|
|
extra={"event": "comms_seed_room", "user": user, "detail": message},
|
|
)
|
|
continue
|
|
raise
|
|
room_id = self._ensure_room(client, token)
|
|
self._join_user(client, token, room_id, _canon_user(self._settings.comms_bot_user, self._settings.comms_server_name))
|
|
self._join_all_locals(client, token, room_id)
|
|
return {"status": "ok", "detail": "room seeded"}
|
|
|
|
def _login(self, client: httpx.Client, user: str, password: str) -> str:
|
|
resp = client.post(
|
|
f"{self._settings.comms_auth_base}/_matrix/client/v3/login",
|
|
json={
|
|
"type": "m.login.password",
|
|
"identifier": {"type": "m.id.user", "user": _canon_user(user, self._settings.comms_server_name)},
|
|
"password": password,
|
|
},
|
|
)
|
|
if resp.status_code != HTTP_OK:
|
|
raise RuntimeError(f"login failed: {resp.status_code} {resp.text}")
|
|
payload = resp.json()
|
|
token = payload.get("access_token")
|
|
if not isinstance(token, str) or not token:
|
|
raise RuntimeError("login missing token")
|
|
return token
|
|
|
|
def _login_with_retry(self, client: httpx.Client, user: str, password: str) -> str:
|
|
last: Exception | None = None
|
|
for attempt in range(1, 6):
|
|
try:
|
|
return self._login(client, user, password)
|
|
except Exception as exc: # noqa: BLE001
|
|
last = exc
|
|
self._sleep(attempt * 2)
|
|
raise RuntimeError(str(last) if last else "login failed")
|
|
|
|
def _resolve_alias(self, client: httpx.Client, token: str, alias: str) -> str:
|
|
resp = client.get(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/directory/room/{urllib.parse.quote(alias)}",
|
|
headers=_auth(token),
|
|
)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
return payload["room_id"]
|
|
|
|
def _get_pinned(self, client: httpx.Client, token: str, room_id: str) -> list[str]:
|
|
resp = client.get(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/state/m.room.pinned_events",
|
|
headers=_auth(token),
|
|
)
|
|
if resp.status_code == HTTP_NOT_FOUND:
|
|
return []
|
|
resp.raise_for_status()
|
|
pinned = resp.json().get("pinned", [])
|
|
return [item for item in pinned if isinstance(item, str)]
|
|
|
|
def _get_event(self, client: httpx.Client, token: str, room_id: str, event_id: str) -> dict[str, Any] | None:
|
|
resp = client.get(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/event/{urllib.parse.quote(event_id)}",
|
|
headers=_auth(token),
|
|
)
|
|
if resp.status_code == HTTP_NOT_FOUND:
|
|
return None
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
def _send_message(self, client: httpx.Client, token: str, room_id: str, body: str) -> str:
|
|
resp = client.post(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/send/m.room.message",
|
|
headers=_auth(token),
|
|
json={"msgtype": "m.text", "body": body},
|
|
)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
event_id = payload.get("event_id")
|
|
return event_id if isinstance(event_id, str) else ""
|
|
|
|
def _pin_message(self, client: httpx.Client, token: str, room_id: str, event_id: str) -> None:
|
|
resp = client.put(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/state/m.room.pinned_events",
|
|
headers=_auth(token),
|
|
json={"pinned": [event_id]},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
def _create_room(self, client: httpx.Client, token: str, name: str) -> str:
|
|
resp = client.post(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/createRoom",
|
|
headers=_auth(token),
|
|
json={"preset": "public_chat", "name": name, "room_version": "11"},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()["room_id"]
|
|
|
|
def _set_room_state(self, client: httpx.Client, token: str, room_id: str, ev_type: str, content: dict[str, Any]) -> None:
|
|
resp = client.put(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/state/{ev_type}",
|
|
headers=_auth(token),
|
|
json=content,
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
def _set_directory_visibility(self, client: httpx.Client, token: str, room_id: str, visibility: str) -> None:
|
|
resp = client.put(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/directory/list/room/{urllib.parse.quote(room_id)}",
|
|
headers=_auth(token),
|
|
json={"visibility": visibility},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
def _delete_alias(self, client: httpx.Client, token: str, alias: str) -> None:
|
|
resp = client.delete(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/directory/room/{urllib.parse.quote(alias)}",
|
|
headers=_auth(token),
|
|
)
|
|
if resp.status_code in (HTTP_OK, HTTP_ACCEPTED, HTTP_NOT_FOUND):
|
|
return
|
|
resp.raise_for_status()
|
|
|
|
def _put_alias(self, client: httpx.Client, token: str, alias: str, room_id: str) -> None:
|
|
resp = client.put(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/directory/room/{urllib.parse.quote(alias)}",
|
|
headers=_auth(token),
|
|
json={"room_id": room_id},
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
def _list_joined_members(self, client: httpx.Client, token: str, room_id: str) -> list[str]:
|
|
resp = client.get(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/members?membership=join",
|
|
headers=_auth(token),
|
|
)
|
|
resp.raise_for_status()
|
|
members = []
|
|
for ev in resp.json().get("chunk", []) or []:
|
|
if ev.get("type") != "m.room.member":
|
|
continue
|
|
uid = ev.get("state_key")
|
|
if isinstance(uid, str) and uid.startswith("@"):
|
|
members.append(uid)
|
|
return members
|
|
|
|
def _invite_user(self, client: httpx.Client, token: str, room_id: str, user_id: str) -> None:
|
|
resp = client.post(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/invite",
|
|
headers=_auth(token),
|
|
json={"user_id": user_id},
|
|
)
|
|
if resp.status_code in (HTTP_OK, HTTP_ACCEPTED):
|
|
return
|
|
resp.raise_for_status()
|
|
|
|
def _power_levels(self) -> dict[str, Any]:
|
|
return {
|
|
"ban": 50,
|
|
"events": {
|
|
"m.room.avatar": 50,
|
|
"m.room.canonical_alias": 50,
|
|
"m.room.encryption": 100,
|
|
"m.room.history_visibility": 100,
|
|
"m.room.name": 50,
|
|
"m.room.power_levels": 100,
|
|
"m.room.server_acl": 100,
|
|
"m.room.tombstone": 100,
|
|
},
|
|
"events_default": 0,
|
|
"historical": 100,
|
|
"invite": 50,
|
|
"kick": 50,
|
|
"m.call.invite": 50,
|
|
"redact": 50,
|
|
"state_default": 50,
|
|
"users": {_canon_user(self._settings.comms_seeder_user, self._settings.comms_server_name): 100},
|
|
"users_default": 0,
|
|
}
|
|
|
|
def _ensure_user(self, client: httpx.Client, token: str, localpart: str, password: str, admin: bool) -> None:
|
|
admin_token = self._admin_token(token)
|
|
user_id = _canon_user(localpart, self._settings.comms_server_name)
|
|
url = f"{self._settings.comms_synapse_base}/_synapse/admin/v2/users/{urllib.parse.quote(user_id)}"
|
|
resp = client.get(url, headers=_auth(admin_token))
|
|
if resp.status_code == HTTP_OK:
|
|
return
|
|
payload = {"password": password, "admin": admin, "deactivated": False}
|
|
create = client.put(url, headers=_auth(admin_token), json=payload)
|
|
if create.status_code not in (HTTP_OK, HTTP_CREATED):
|
|
raise RuntimeError(f"create user {user_id} failed: {create.status_code} {create.text}")
|
|
|
|
def _ensure_room(self, client: httpx.Client, token: str) -> str:
|
|
alias = self._settings.comms_room_alias
|
|
alias_enc = urllib.parse.quote(alias)
|
|
exists = client.get(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/directory/room/{alias_enc}",
|
|
headers=_auth(token),
|
|
)
|
|
if exists.status_code == HTTP_OK:
|
|
room_id = exists.json()["room_id"]
|
|
else:
|
|
create = client.post(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/createRoom",
|
|
headers=_auth(token),
|
|
json={
|
|
"preset": "public_chat",
|
|
"name": self._settings.comms_room_name,
|
|
"room_alias_name": alias.split(":", 1)[0].lstrip("#"),
|
|
"initial_state": [],
|
|
"power_level_content_override": {
|
|
"events_default": 0,
|
|
"users_default": 0,
|
|
"state_default": 50,
|
|
},
|
|
},
|
|
)
|
|
if create.status_code not in (HTTP_OK, HTTP_CONFLICT):
|
|
raise RuntimeError(f"create room failed: {create.status_code} {create.text}")
|
|
exists = client.get(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/directory/room/{alias_enc}",
|
|
headers=_auth(token),
|
|
)
|
|
room_id = exists.json()["room_id"]
|
|
|
|
state_events = [
|
|
("m.room.join_rules", {"join_rule": "public"}),
|
|
("m.room.guest_access", {"guest_access": "can_join"}),
|
|
("m.room.history_visibility", {"history_visibility": "shared"}),
|
|
("m.room.canonical_alias", {"alias": alias}),
|
|
]
|
|
for ev_type, content in state_events:
|
|
client.put(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/rooms/{room_id}/state/{ev_type}",
|
|
headers=_auth(token),
|
|
json=content,
|
|
)
|
|
client.put(
|
|
f"{self._settings.comms_synapse_base}/_matrix/client/v3/directory/list/room/{room_id}",
|
|
headers=_auth(token),
|
|
json={"visibility": "public"},
|
|
)
|
|
return room_id
|
|
|
|
def _join_user(self, client: httpx.Client, token: str, room_id: str, user_id: str) -> None:
|
|
admin_token = self._admin_token(token)
|
|
client.post(
|
|
f"{self._settings.comms_synapse_base}/_synapse/admin/v1/join/{urllib.parse.quote(room_id)}",
|
|
headers=_auth(admin_token),
|
|
json={"user_id": user_id},
|
|
)
|
|
|
|
def _join_all_locals(self, client: httpx.Client, token: str, room_id: str) -> None:
|
|
users: list[str] = []
|
|
from_token = None
|
|
admin_token = self._admin_token(token)
|
|
while True:
|
|
url = f"{self._settings.comms_synapse_base}/_synapse/admin/v2/users?local=true&deactivated=false&limit=100"
|
|
if from_token:
|
|
url += f"&from={from_token}"
|
|
resp = client.get(url, headers=_auth(admin_token))
|
|
payload = resp.json()
|
|
users.extend([u["name"] for u in payload.get("users", []) if isinstance(u, dict) and u.get("name")])
|
|
from_token = payload.get("next_token")
|
|
if not from_token:
|
|
break
|
|
for uid in users:
|
|
self._join_user(client, token, room_id, uid)
|