diff --git a/services/comms/NOTES.md b/services/comms/NOTES.md index e6868fe..f3ba78f 100644 --- a/services/comms/NOTES.md +++ b/services/comms/NOTES.md @@ -17,13 +17,12 @@ Operational jobs - synapse-user-seed-job: seeds atlasbot + othrys-seeder users/passwords. - mas-local-users-ensure-job: ensures MAS local users exist (seeder/bot). - seed-othrys-room: (suspended) creates Othrys + joins locals. -- reset-othrys-room: one-off room reset + pin invite. +- reset-othrys-room: suspended CronJob for a manual room reset + pin invite. - pin-othrys-invite: (suspended) pin invite message if missing. - guest-name-randomizer: renames numeric/guest users to adj-noun names. - bstein-force-leave: one-off room leave cleanup. Manual re-runs -- Bump the job name suffix (e.g., reset-othrys-room-9) to re-run a one-off job. - Unsuspend a CronJob only when needed; re-suspend after completion. Ports diff --git a/services/comms/reset-othrys-room-job.yaml b/services/comms/reset-othrys-room-job.yaml index 9657626..dd056c3 100644 --- a/services/comms/reset-othrys-room-job.yaml +++ b/services/comms/reset-othrys-room-job.yaml @@ -1,82 +1,93 @@ # services/comms/reset-othrys-room-job.yaml apiVersion: batch/v1 -kind: Job +kind: CronJob metadata: - name: othrys-room-reset-8 + name: othrys-room-reset namespace: comms spec: - backoffLimit: 0 - template: + schedule: "0 0 1 1 *" + suspend: true + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 1 + jobTemplate: spec: - restartPolicy: Never - containers: - - name: reset - image: python:3.11-slim - env: - - name: SYNAPSE_BASE - value: http://matrix-authentication-service:8080 - - name: AUTH_BASE - value: http://matrix-authentication-service:8080 - - name: SERVER_NAME - value: live.bstein.dev - - name: ROOM_ALIAS - value: "#othrys:live.bstein.dev" - - name: ROOM_NAME - value: Othrys - - name: PIN_MESSAGE - value: "Invite guests: share https://live.bstein.dev/#/room/#othrys:live.bstein.dev?action=join and choose 'Continue' -> 'Join as guest'." - - name: SEEDER_USER - value: othrys-seeder - - name: SEEDER_PASS - valueFrom: - secretKeyRef: - name: atlasbot-credentials-runtime - key: seeder-password - - name: BOT_USER - value: atlasbot - command: - - /bin/sh - - -c - - | - set -euo pipefail - pip install --no-cache-dir requests >/dev/null - python - <<'PY' - import os, sys, time, urllib.parse, requests + backoffLimit: 0 + template: + spec: + restartPolicy: Never + containers: + - name: reset + image: python:3.11-slim + env: + - name: SYNAPSE_BASE + value: http://othrys-synapse-matrix-synapse:8008 + - name: AUTH_BASE + value: http://matrix-authentication-service:8080 + - name: SERVER_NAME + value: live.bstein.dev + - name: ROOM_ALIAS + value: "#othrys:live.bstein.dev" + - name: ROOM_NAME + value: Othrys + - name: PIN_MESSAGE + value: "Invite guests: share https://live.bstein.dev/#/room/#othrys:live.bstein.dev?action=join and choose 'Continue' -> 'Join as guest'." + - name: SEEDER_USER + value: othrys-seeder + - name: SEEDER_PASS + valueFrom: + secretKeyRef: + name: atlasbot-credentials-runtime + key: seeder-password + - name: BOT_USER + value: atlasbot + command: + - /bin/sh + - -c + - | + set -euo pipefail + pip install --no-cache-dir requests >/dev/null + python - <<'PY' + import os + import time + import urllib.parse + import requests - BASE = os.environ["SYNAPSE_BASE"] - AUTH_BASE = os.environ.get("AUTH_BASE", BASE) - SERVER_NAME = os.environ.get("SERVER_NAME", "live.bstein.dev") - ROOM_ALIAS = os.environ.get("ROOM_ALIAS", "#othrys:live.bstein.dev") - ROOM_NAME = os.environ.get("ROOM_NAME", "Othrys") - PIN_MESSAGE = os.environ["PIN_MESSAGE"] - SEEDER_USER = os.environ["SEEDER_USER"] - SEEDER_PASS = os.environ["SEEDER_PASS"] - BOT_USER = os.environ["BOT_USER"] + BASE = os.environ["SYNAPSE_BASE"] + AUTH_BASE = os.environ.get("AUTH_BASE", BASE) + SERVER_NAME = os.environ.get("SERVER_NAME", "live.bstein.dev") + ROOM_ALIAS = os.environ.get("ROOM_ALIAS", "#othrys:live.bstein.dev") + ROOM_NAME = os.environ.get("ROOM_NAME", "Othrys") + PIN_MESSAGE = os.environ["PIN_MESSAGE"] + SEEDER_USER = os.environ["SEEDER_USER"] + SEEDER_PASS = os.environ["SEEDER_PASS"] + BOT_USER = os.environ["BOT_USER"] - POWER_LEVELS = { - "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": {f"@{SEEDER_USER}:{SERVER_NAME}": 100}, - "users_default": 0, - } + POWER_LEVELS = { + "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": {f"@{SEEDER_USER}:{SERVER_NAME}": 100}, + "users_default": 0, + } - def auth(token): return {"Authorization": f"Bearer {token}"} + def auth(token): + return {"Authorization": f"Bearer {token}"} def canon_user(user): u = (user or "").strip() @@ -85,155 +96,171 @@ spec: u = u.lstrip("@") if ":" in u: return f"@{u}" - return f"@{u}:live.bstein.dev" + return f"@{u}:{SERVER_NAME}" def login(user, password): - r = requests.post(f"{AUTH_BASE}/_matrix/client/v3/login", json={ - "type": "m.login.password", - "identifier": {"type": "m.id.user", "user": canon_user(user)}, - "password": password, - }) - if r.status_code != 200: - raise SystemExit(f"login failed: {r.status_code} {r.text}") - return r.json()["access_token"] + r = requests.post( + f"{AUTH_BASE}/_matrix/client/v3/login", + json={ + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": canon_user(user)}, + "password": password, + }, + ) + if r.status_code != 200: + raise SystemExit(f"login failed: {r.status_code} {r.text}") + return r.json()["access_token"] - def resolve_alias(token, alias): - enc = urllib.parse.quote(alias) - r = requests.get(f"{BASE}/_matrix/client/v3/directory/room/{enc}", headers=auth(token)) - if r.status_code == 404: - return None - r.raise_for_status() - return r.json()["room_id"] + def resolve_alias(token, alias): + enc = urllib.parse.quote(alias) + r = requests.get(f"{BASE}/_matrix/client/v3/directory/room/{enc}", headers=auth(token)) + if r.status_code == 404: + return None + r.raise_for_status() + return r.json()["room_id"] - def create_room(token): - r = requests.post(f"{BASE}/_matrix/client/v3/createRoom", headers=auth(token), json={ - "preset": "public_chat", - "name": ROOM_NAME, - "room_version": "11", - }) - r.raise_for_status() - return r.json()["room_id"] + def create_room(token): + r = requests.post( + f"{BASE}/_matrix/client/v3/createRoom", + headers=auth(token), + json={ + "preset": "public_chat", + "name": ROOM_NAME, + "room_version": "11", + }, + ) + r.raise_for_status() + return r.json()["room_id"] - def put_state(token, room_id, ev_type, content): - r = requests.put( - f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/state/{ev_type}", - headers=auth(token), - json=content, - ) - r.raise_for_status() + def put_state(token, room_id, ev_type, content): + r = requests.put( + f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/state/{ev_type}", + headers=auth(token), + json=content, + ) + r.raise_for_status() - def set_directory_visibility(token, room_id, visibility): - r = requests.put( - f"{BASE}/_matrix/client/v3/directory/list/room/{urllib.parse.quote(room_id)}", - headers=auth(token), - json={"visibility": visibility}, - ) - r.raise_for_status() + def set_directory_visibility(token, room_id, visibility): + r = requests.put( + f"{BASE}/_matrix/client/v3/directory/list/room/{urllib.parse.quote(room_id)}", + headers=auth(token), + json={"visibility": visibility}, + ) + r.raise_for_status() - def delete_alias(token, alias): - enc = urllib.parse.quote(alias) - r = requests.delete(f"{BASE}/_matrix/client/v3/directory/room/{enc}", headers=auth(token)) - if r.status_code in (200, 202, 404): - return - r.raise_for_status() + def delete_alias(token, alias): + enc = urllib.parse.quote(alias) + r = requests.delete(f"{BASE}/_matrix/client/v3/directory/room/{enc}", headers=auth(token)) + if r.status_code in (200, 202, 404): + return + r.raise_for_status() - def put_alias(token, alias, room_id): - enc = urllib.parse.quote(alias) - r = requests.put( - f"{BASE}/_matrix/client/v3/directory/room/{enc}", - headers=auth(token), - json={"room_id": room_id}, - ) - r.raise_for_status() + def put_alias(token, alias, room_id): + enc = urllib.parse.quote(alias) + r = requests.put( + f"{BASE}/_matrix/client/v3/directory/room/{enc}", + headers=auth(token), + json={"room_id": room_id}, + ) + r.raise_for_status() - def list_joined_members(token, room_id): - r = requests.get( - f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/members?membership=join", - headers=auth(token), - ) - r.raise_for_status() - members = [] - for ev in r.json().get("chunk", []): - if ev.get("type") != "m.room.member": + def list_joined_members(token, room_id): + r = requests.get( + f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/members?membership=join", + headers=auth(token), + ) + r.raise_for_status() + members = [] + for ev in r.json().get("chunk", []): + if ev.get("type") != "m.room.member": + continue + uid = ev.get("state_key") + if not isinstance(uid, str) or not uid.startswith("@"): + continue + members.append(uid) + return members + + def invite_user(token, room_id, user_id): + r = requests.post( + f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/invite", + headers=auth(token), + json={"user_id": user_id}, + ) + if r.status_code in (200, 202): + return + r.raise_for_status() + + def send_message(token, room_id, body): + r = requests.post( + f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/send/m.room.message", + headers=auth(token), + json={"msgtype": "m.text", "body": body}, + ) + r.raise_for_status() + return r.json()["event_id"] + + def login_with_retry(): + last = None + for attempt in range(1, 6): + try: + return login(SEEDER_USER, SEEDER_PASS) + except Exception as exc: # noqa: BLE001 + last = exc + time.sleep(attempt * 2) + raise last + + token = login_with_retry() + + old_room_id = resolve_alias(token, ROOM_ALIAS) + if not old_room_id: + raise SystemExit(f"alias {ROOM_ALIAS} not found; refusing to proceed") + + new_room_id = create_room(token) + + # Configure the new room. + put_state(token, new_room_id, "m.room.join_rules", {"join_rule": "public"}) + put_state(token, new_room_id, "m.room.guest_access", {"guest_access": "can_join"}) + put_state(token, new_room_id, "m.room.history_visibility", {"history_visibility": "shared"}) + put_state(token, new_room_id, "m.room.power_levels", POWER_LEVELS) + + # Move the alias. + delete_alias(token, ROOM_ALIAS) + put_alias(token, ROOM_ALIAS, new_room_id) + put_state(token, new_room_id, "m.room.canonical_alias", {"alias": ROOM_ALIAS}) + + set_directory_visibility(token, new_room_id, "public") + + # Invite the bot and all joined members of the old room. + bot_user_id = f"@{BOT_USER}:{SERVER_NAME}" + invite_user(token, new_room_id, bot_user_id) + for uid in list_joined_members(token, old_room_id): + if uid == f"@{SEEDER_USER}:{SERVER_NAME}": continue - uid = ev.get("state_key") - if not isinstance(uid, str) or not uid.startswith("@"): + localpart = uid.split(":", 1)[0].lstrip("@") + if localpart.isdigit(): continue - members.append(uid) - return members + invite_user(token, new_room_id, uid) - def invite_user(token, room_id, user_id): - r = requests.post( - f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/invite", - headers=auth(token), - json={"user_id": user_id}, + # Pin the guest invite message in the new room. + event_id = send_message(token, new_room_id, PIN_MESSAGE) + put_state(token, new_room_id, "m.room.pinned_events", {"pinned": [event_id]}) + + # De-list and tombstone the old room. + set_directory_visibility(token, old_room_id, "private") + put_state(token, old_room_id, "m.room.join_rules", {"join_rule": "invite"}) + put_state(token, old_room_id, "m.room.guest_access", {"guest_access": "forbidden"}) + put_state( + token, + old_room_id, + "m.room.tombstone", + {"body": "Othrys has been reset. Please join the new room.", "replacement_room": new_room_id}, ) - if r.status_code in (200, 202): - return - r.raise_for_status() - - def send_message(token, room_id, body): - r = requests.post( - f"{BASE}/_matrix/client/v3/rooms/{urllib.parse.quote(room_id)}/send/m.room.message", - headers=auth(token), - json={"msgtype": "m.text", "body": body}, + send_message( + token, + old_room_id, + "Othrys was reset. Join the new room at https://live.bstein.dev/#/room/#othrys:live.bstein.dev?action=join", ) - r.raise_for_status() - return r.json()["event_id"] - def login_with_retry(): - last = None - for attempt in range(1, 6): - try: - return login(SEEDER_USER, SEEDER_PASS) - except Exception as exc: # noqa: BLE001 - last = exc - time.sleep(attempt * 2) - raise last - - token = login_with_retry() - - old_room_id = resolve_alias(token, ROOM_ALIAS) - if not old_room_id: - raise SystemExit(f"alias {ROOM_ALIAS} not found; refusing to proceed") - - new_room_id = create_room(token) - - # Configure the new room. - put_state(token, new_room_id, "m.room.join_rules", {"join_rule": "public"}) - put_state(token, new_room_id, "m.room.guest_access", {"guest_access": "can_join"}) - put_state(token, new_room_id, "m.room.history_visibility", {"history_visibility": "shared"}) - put_state(token, new_room_id, "m.room.power_levels", POWER_LEVELS) - - # Move the alias. - delete_alias(token, ROOM_ALIAS) - put_alias(token, ROOM_ALIAS, new_room_id) - put_state(token, new_room_id, "m.room.canonical_alias", {"alias": ROOM_ALIAS}) - - set_directory_visibility(token, new_room_id, "public") - - # Invite the bot and all joined members of the old room. - bot_user_id = f"@{BOT_USER}:{SERVER_NAME}" - invite_user(token, new_room_id, bot_user_id) - for uid in list_joined_members(token, old_room_id): - if uid == f"@{SEEDER_USER}:{SERVER_NAME}": - continue - localpart = uid.split(":", 1)[0].lstrip("@") - if localpart.isdigit(): - continue - invite_user(token, new_room_id, uid) - - # Pin the guest invite message in the new room. - event_id = send_message(token, new_room_id, PIN_MESSAGE) - put_state(token, new_room_id, "m.room.pinned_events", {"pinned": [event_id]}) - - # De-list and tombstone the old room. - set_directory_visibility(token, old_room_id, "private") - put_state(token, old_room_id, "m.room.join_rules", {"join_rule": "invite"}) - put_state(token, old_room_id, "m.room.guest_access", {"guest_access": "forbidden"}) - put_state(token, old_room_id, "m.room.tombstone", {"body": "Othrys has been reset. Please join the new room.", "replacement_room": new_room_id}) - send_message(token, old_room_id, "Othrys was reset. Join the new room at https://live.bstein.dev/#/room/#othrys:live.bstein.dev?action=join") - - print(f"old_room_id={old_room_id}") - print(f"new_room_id={new_room_id}") - PY + print(f"old_room_id={old_room_id}") + print(f"new_room_id={new_room_id}") + PY