#!/usr/bin/env bash # register-matrix-bot.sh — provision the Matrix bot account the maintenance # agent uses to talk to the operator, and a private room shared with the admin. # # This homeserver authenticates humans via Keycloak OIDC and has password login # DISABLED (synapse `password_config.enabled: false`). Bot/agent accounts # therefore cannot use the password-login flow — they are created and given an # access token via Synapse's shared-secret admin API # (/_synapse/admin/v1/register), which works even with registration + password # login turned off and returns a standalone access token at creation time. # # (Note: the admin "login as user" API instead returns a *puppet* token that # Synapse revokes the moment the issuing admin is deactivated, so we don't use # it — we capture the standalone token from the bot's own register call.) # # Idempotent: re-running re-uses the existing access token in config.json when it # still validates, and the existing room. If the account exists but no valid # token is on hand (e.g. fresh checkout), it re-creates the account to mint a # fresh standalone token. # # Requirements: run on the core-stack host, with the `user-synapse` container # running (the registration shared secret is read from it). # # Usage: # ./register-matrix-bot.sh # uses config.json next to this script # CONFIG=/path/config.json ./register-matrix-bot.sh set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG="${CONFIG:-$DIR/config.json}" SECRETS="$DIR/.bot-secrets" # gitignored: records which bot account was provisioned SYNAPSE_CONTAINER="${SYNAPSE_CONTAINER:-user-synapse}" if [ ! -f "$CONFIG" ]; then echo "ERROR: $CONFIG not found. Copy config.example.json to config.json first." exit 1 fi cfg_get() { python3 -c "import json,sys; print(json.load(open('$CONFIG'))$1)"; } HOMESERVER="$(cfg_get "['matrix']['homeserver']")" BOT_USER_ID="$(cfg_get "['matrix']['user_id']")" ADMIN_USER_ID="$(cfg_get "['matrix']['admin_user_id']")" ROOM_ID="$(cfg_get "['matrix'].get('room_id','')" 2>/dev/null || echo "")" # localpart from @claude-code:semprini.me -> claude-code BOT_LOCAL="${BOT_USER_ID#@}"; BOT_LOCAL="${BOT_LOCAL%%:*}" echo "→ Homeserver : $HOMESERVER" echo "→ Bot : $BOT_USER_ID" echo "→ Admin : $ADMIN_USER_ID" # Read the registration shared secret straight out of the running synapse # container so we don't depend on a host path to homeserver.yaml / .env. echo "→ Reading registration shared secret from $SYNAPSE_CONTAINER…" SHARED_SECRET="$(docker exec "$SYNAPSE_CONTAINER" \ sh -c "grep -E '^[[:space:]]*registration_shared_secret:' /data/homeserver.yaml" \ | sed -E 's/^[^:]*:[[:space:]]*//; s/^"//; s/"[[:space:]]*$//')" [ -n "$SHARED_SECRET" ] || { echo "ERROR: could not read registration_shared_secret"; exit 1; } # All the Matrix work happens in one Python block (stdlib only): ensure the bot # has a valid standalone access token, ensure the room, and write # access_token + room_id back into config.json. python3 - "$CONFIG" "$HOMESERVER" "$SHARED_SECRET" "$BOT_LOCAL" "$BOT_USER_ID" "$ADMIN_USER_ID" "$ROOM_ID" <<'PY' import hmac, hashlib, json, secrets, sys, urllib.request, urllib.error config, hs, shared, bot_local, bot_id, admin_id, room_id = sys.argv[1:8] hs = hs.rstrip("/") domain = bot_id.split(":", 1)[1] REG = hs + "/_synapse/admin/v1/register" def api(url, data=None, token=None, method=None): m = method or ("POST" if data is not None else "GET") r = urllib.request.Request( url, data=(json.dumps(data).encode() if data is not None else None), method=m) r.add_header("Content-Type", "application/json") if token: r.add_header("Authorization", "Bearer " + token) try: with urllib.request.urlopen(r, timeout=60) as resp: return resp.status, (json.loads(resp.read() or b"{}")) except urllib.error.HTTPError as e: try: return e.code, json.loads(e.read()) except Exception: return e.code, {} def shared_secret_register(username, admin): """Create a user via the shared-secret admin API; returns (code, body). On success the body carries a standalone access_token for the new user.""" nonce = api(REG)[1]["nonce"] pw = secrets.token_hex(32) mac = hmac.new(shared.encode(), digestmod=hashlib.sha1) for part in (nonce, username, pw): mac.update(part.encode()); mac.update(b"\x00") mac.update(b"admin" if admin else b"notadmin") return api(REG, {"nonce": nonce, "username": username, "password": pw, "admin": admin, "mac": mac.hexdigest()}) def token_valid(token): if not token or token.startswith("FILLED_BY_"): return False code, body = api(hs + "/_matrix/client/v3/account/whoami", token=token) return code == 200 and body.get("user_id") == bot_id # 1. Obtain a valid standalone access token for the bot. existing = json.load(open(config)).get("matrix", {}).get("access_token", "") code, body = shared_secret_register(bot_local, admin=False) if code == 200: bot_token = body["access_token"] print(f"→ Created bot account {bot_id} (new access token)") elif token_valid(existing): bot_token = existing print(f"→ Bot account {bot_id} exists; reusing valid token from config.json") else: # Account exists but we have no valid token. The only password-free way to # mint a fresh *standalone* token is to recreate the account: register a # throwaway admin, deactivate the bot, recreate it (capturing the token), # then deactivate the throwaway admin. print(f"→ Bot account {bot_id} exists but no valid token — recreating it") admin_token = shared_secret_register("maint-bootstrap-" + secrets.token_hex(4), admin=True)[1]["access_token"] api(hs + f"/_synapse/admin/v1/deactivate/{bot_id}", {"erase": False}, token=admin_token) c, b = shared_secret_register(bot_local, admin=False) if c != 200: sys.exit(f"ERROR recreating bot: {c} {b}") bot_token = b["access_token"] room_id = "" # the recreated account is in no rooms # Find and deactivate the throwaway admin we just made. for u in api(hs + "/_synapse/admin/v2/users?from=0&limit=500", token=admin_token)[1].get("users", []): if u["name"].startswith("@maint-bootstrap-"): api(hs + f"/_synapse/admin/v1/deactivate/{u['name']}", {"erase": True}, token=admin_token) # 2. Set a friendly display name. api(hs + f"/_matrix/client/v3/profile/{bot_id}/displayname", {"displayname": "Stack Maintainer"}, token=bot_token, method="PUT") # 4. Ensure a private DM room with the admin invited. joined = api(hs + "/_matrix/client/v3/joined_rooms", token=bot_token)[1].get("joined_rooms", []) if room_id and not room_id.startswith("FILLED_BY_") and room_id in joined: print(f"→ Re-using existing room {room_id}") else: code, body = api(hs + "/_matrix/client/v3/createRoom", { "name": "Stack Maintenance", "topic": "semprini-core autonomous stack maintenance", "preset": "trusted_private_chat", "is_direct": True, "invite": [admin_id]}, token=bot_token) room_id = body.get("room_id") if not room_id: sys.exit(f"ERROR creating room: {code} {body}") print(f"→ Created room {room_id}, invited {admin_id}") # 5. Write token + room back into config.json. cfg = json.load(open(config)) cfg.setdefault("matrix", {}) cfg["matrix"]["access_token"] = bot_token cfg["matrix"]["room_id"] = room_id json.dump(cfg, open(config, "w"), indent=2) print(f"→ Wrote access_token + room_id to {config}") PY # Record which account was provisioned (token lives only in config.json). umask 077; printf 'BOT_USER=%s\n' "$BOT_USER_ID" > "$SECRETS" echo echo "✓ Done." echo " ACTION REQUIRED (one-time): in Element (chat.semprini.me) as" echo " $ADMIN_USER_ID, accept the invite to the 'Stack Maintenance' room."