Files
semprini-maintainer/register-matrix-bot.sh
T
paulandClaude Sonnet 4.6 e14c21fab5 Fix 429 (model pin) and bot ignoring encrypted admin replies
Two independent bugs:

1. 429 "long context" — agentic sessions read enough files to exceed the
   200K standard window; on the default [1m] model Synapse escalates into
   the paid 1M tier. Pin claude_model to claude-opus-4-8 (200K window)
   so Claude Code compacts context instead. Updated config.example.json
   and the maintainer.py default / comment accordingly.

2. Bot silently ignores operator replies — the DM room was created with
   is_direct:true, causing Element to auto-enable E2E encryption. This
   stdlib-only bot cannot decrypt m.room.encrypted events, so all admin
   messages were dropped without any log output. Fix:
   - register-matrix-bot.sh now creates rooms with is_direct:false and
     preset:private_chat (Synapse does not force encryption on non-DM
     rooms).
   - matrix_poll_admin() now logs a loud WARN if an encrypted event
     arrives, instead of silently skipping it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 18:46:33 +12:00

173 lines
8.1 KiB
Bash
Executable File

#!/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:
# NB: is_direct must be False. A direct (DM) room makes Element auto-enable
# E2E encryption, which this stdlib-only bot cannot decrypt — it would then
# silently never see the operator's replies. A plain private room stays
# unencrypted (Synapse does not force room encryption).
code, body = api(hs + "/_matrix/client/v3/createRoom", {
"name": "Stack Maintenance",
"topic": "semprini-core autonomous stack maintenance (unencrypted — bot is stdlib-only)",
"preset": "private_chat", "is_direct": False,
"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."