
Matrix Administration
- 15 installs
- 4 repo stars
- Updated August 2, 2026
- netresearch/matrix-skill
Helps with ai & agent building tasks.
About
matrix-administration is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- matrix-administration
- AI & Agent Building
- AI-coding skill
Matrix Administration by the numbers
- 15 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #11,187 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/matrix-skill --skill matrix-administrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 15 |
|---|---|
| repo stars | ★ 4 |
| Last updated | August 2, 2026 |
| Repository | netresearch/matrix-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
Matrix Administration
Stdlib-only Python wrappers around the Synapse Admin API and the Client-Server API. Reads ~/.config/matrix/config.json (the same file matrix-communication uses).
Quick Reference
S=skills/matrix-administration/scripts
python3 $S/synapse-fetch-rooms.py [--server :example.com]
python3 $S/synapse-rate-rooms.py --space '!home:srv' [--language de]
python3 $S/synapse-graph.py --space '!home:srv'
python3 $S/synapse-biggest-rooms.py [-n 10]
python3 $S/synapse-join-room.py '!room:srv' '@user:srv'
python3 $S/synapse-make-admin.py '!room:srv' '@user:srv'
python3 $S/synapse-add-to-space.py '!room:srv' '!space:srv'
python3 $S/synapse-migrate-room.py '!room:srv' '@admin:srv' '!space:srv'
python3 $S/synapse-deactivate-user.py '@user:srv' # DESTRUCTIVE
python3 $S/synapse-user-rooms.py '@user:srv'
python3 $S/synapse-user-admin-rooms.py '@user:srv'
python3 $S/synapse-room-member-flow.py '!room:srv'
python3 $S/synapse-search.py '!room:srv' '@bot:srv' deployScripts
| Script | Purpose |
|---|---|
synapse-fetch-rooms.py | Snapshot all rooms + state → rooms.json |
synapse-rate-rooms.py | Health checks (EN/DE) on rooms.json |
synapse-graph.py | Render rooms.json → Graphviz .dot + .svg |
synapse-biggest-rooms.py | Top-N rooms by DB size |
synapse-join-room.py | Force-join a user |
synapse-make-admin.py | Promote to PL 100 |
synapse-add-to-space.py | Send m.space.child |
synapse-migrate-room.py | Add to space + restrict + encrypt |
synapse-deactivate-user.py | Destructive deactivation (--erase for GDPR) |
synapse-user-admin-rooms.py | Local: rooms where user is PL 100 |
synapse-user-rooms.py | Local: rooms a user belongs to |
synapse-room-member-flow.py | Join/leave timeline |
synapse-search.py | Unencrypted history search |
Setup
~/.config/matrix/config.json (respects $XDG_CONFIG_HOME):
{
"homeserver": "https://matrix.example.com",
"admin_token": "syt_admin_…",
"room_filter": ":example.com",
"default_space_id": "!home:example.com",
"home_space_ids": ["!home:example.com"]
}homeserver and either admin_token or access_token (server-admin) are required. The other fields are optional. Env fallbacks: MATRIX_USER_ID, MATRIX_SPACE_ID, LANGUAGE=en|de, NO_COLOR.
Safety
The token is server-admin. Read `references/safety-guide.md` before any destructive op.
synapse-deactivate-user.pyis irreversible.synapse-migrate-room.pyenables encryption (one-way) and restricts joins; power-level changes restored on exit including Ctrl-C.synapse-make-admin.pyraises PL 100 permanently.synapse-search.pycannot read E2EE — empty ≠ no messages.rooms.jsonexposes user IDs; never commit it.
References
- `references/synapse-admin-api.md` — endpoints + upstream docs
- `references/room-health-checks.md` — rules + remediation
- `references/room-graph-pipeline.md` — Docker dashboard recipe
- `references/safety-guide.md` — destructive-op checklist
- Source: netresearch/matrix-skill
{
"skill_name": "matrix-administration",
"evals": [
{
"id": 1,
"eval_name": "snapshot-rooms",
"prompt": "Snapshot every room on the configured Synapse homeserver into rooms.json.",
"expected_output": "Runs synapse-fetch-rooms.py.",
"files": [],
"assertions": [
"Invokes synapse-fetch-rooms.py via python3",
"Does not pass --server unless the user specified a server suffix",
"Does not commit or print the resulting rooms.json"
]
},
{
"id": 2,
"eval_name": "rate-rooms-de",
"prompt": "Rate the rooms in rooms.json with German output, treating !home:example.com as our home space.",
"expected_output": "Runs synapse-rate-rooms.py with --language de --space '!home:example.com'.",
"files": [],
"assertions": [
"Invokes synapse-rate-rooms.py",
"Passes --language de (or sets LANGUAGE=de)",
"Passes --space '!home:example.com'"
]
},
{
"id": 3,
"eval_name": "render-graph",
"prompt": "Render an SVG of the room tree.",
"expected_output": "Runs synapse-graph.py and uses any home_space_ids from the config or asks the user.",
"files": [],
"assertions": [
"Invokes synapse-graph.py",
"Surfaces that graphviz `dot` is required for the SVG step (or uses --no-svg)"
]
},
{
"id": 4,
"eval_name": "deactivate-user-confirm",
"prompt": "Deactivate @leaver:example.com.",
"expected_output": "Confirms before running synapse-deactivate-user.py because the operation is irreversible.",
"files": [],
"assertions": [
"Surfaces that the operation is irreversible",
"Does NOT silently pass --yes",
"Asks the user about --erase (GDPR removal of messages)"
]
},
{
"id": 5,
"eval_name": "harden-room",
"prompt": "Make !room:example.com private, add it to !home:example.com, and enable encryption. Use @admin:example.com as the operator.",
"expected_output": "Runs synapse-migrate-room.py with all three IDs.",
"files": [],
"assertions": [
"Invokes synapse-migrate-room.py",
"Passes the room, user and space IDs in the correct order",
"Surfaces that enabling encryption is irreversible"
]
},
{
"id": 6,
"eval_name": "audit-single-admin-rooms",
"prompt": "Find rooms where @alice:example.com is the only admin.",
"expected_output": "Uses rooms.json + synapse-user-admin-rooms.py to filter rooms with no other admins.",
"files": [],
"assertions": [
"Snapshots first if rooms.json is missing/stale",
"Invokes synapse-user-admin-rooms.py",
"Highlights rooms with '(no other admins)'"
]
},
{
"id": 7,
"eval_name": "search-encrypted-warning",
"prompt": "Search !devops:example.com for messages from @bot:example.com containing 'deploy'.",
"expected_output": "Runs synapse-search.py and warns that E2EE messages are invisible.",
"files": [],
"assertions": [
"Invokes synapse-search.py",
"Warns that empty results do not mean no messages if the room is encrypted"
]
},
{
"id": 8,
"eval_name": "biggest-rooms-default",
"prompt": "What are the 5 biggest rooms on the homeserver by DB size?",
"expected_output": "Runs synapse-biggest-rooms.py with -n 5.",
"files": [],
"assertions": [
"Invokes synapse-biggest-rooms.py",
"Passes -n 5 (or --limit 5)",
"Does not require a snapshot — calls the live admin API"
]
},
{
"id": 9,
"eval_name": "member-flow-with-server-filter",
"prompt": "Show the join/leave timeline of !ops:example.com and highlight users from example.com.",
"expected_output": "Runs synapse-room-member-flow.py with the room ID and --server :example.com.",
"files": [],
"assertions": [
"Invokes synapse-room-member-flow.py",
"Passes --server :example.com (or relies on room_filter from config)"
]
},
{
"id": 10,
"eval_name": "add-room-to-space",
"prompt": "Link !room:example.com into !home:example.com as a child.",
"expected_output": "Runs synapse-add-to-space.py with both IDs (or relies on default_space_id from config).",
"files": [],
"assertions": [
"Invokes synapse-add-to-space.py",
"Passes the room ID first, then the space ID",
"Does not enable encryption or change join rules — that's the migrate script's job"
]
}
]
}
Matrix Administration
Operate on a Synapse Matrix homeserver via the Synapse Admin API. Companion to the matrix-communication skill in this repo.
Features
- Snapshot every room —
rooms.jsonof every visible room and its state - Health rating — flag public, unencrypted, or orphaned-from-spaces rooms (English + German)
- Graphviz map — colour-coded SVG of the homeserver's room/space tree
- Force-join, promote admin, link to space — point-and-shoot moderation tools
- Hardening pipeline —
synapse-migrate-room.pyadds a room to a space, restricts joins, enables encryption, and restores power levels - Deactivate users — destructive, with optional GDPR
--erase - Inspection — list a user's admin/membership rooms, replay join/leave timelines, search unencrypted history
- Stdlib-only Python — no third-party dependencies
Installation
Via the Netresearch marketplace (recommended)
/plugin marketplace add netresearch/claude-code-marketplaceThen /install-plugin netresearch/matrix-skill. Both matrix-communication and matrix-administration ship in the same plugin.
Via release download
Grab the latest release and extract to ~/.claude/skills/matrix-administration/.
Configuration
~/.config/matrix/config.json:
{
"homeserver": "https://matrix.example.com",
"admin_token": "syt_admin_…",
"room_filter": ":example.com",
"default_space_id": "!home:example.com",
"home_space_ids": ["!home:example.com"]
}The token must belong to a user with server-admin rights (user_type: 'admin' on the user object). room_filter, default_space_id, and home_space_ids are optional — none of them ship pre-set.
Usage
The skill triggers on Synapse-admin tasks: "list all rooms on the homeserver", "rate room health", "deactivate user @bob", "find rooms with no admin in our org space".
Example prompts
"Snapshot every room on matrix.example.com and rate them in German"
"Render an SVG of the room tree, treating !home:example.com as our home space"
"Deactivate @leaver:example.com and erase their messages"
"Where is @alice:example.com a room admin? Highlight rooms with no other admins."
"Make !room:example.com private, add it to !home:example.com, and enable encryption"Structure
matrix-administration/
├── SKILL.md # AI instructions
├── README.md # this file
├── scripts/
│ ├── _lib/ # stdlib-only shared helpers
│ ├── synapse-fetch-rooms.py
│ ├── synapse-rate-rooms.py
│ ├── synapse-graph.py
│ ├── synapse-biggest-rooms.py
│ ├── synapse-join-room.py
│ ├── synapse-make-admin.py
│ ├── synapse-add-to-space.py
│ ├── synapse-migrate-room.py
│ ├── synapse-deactivate-user.py
│ ├── synapse-user-admin-rooms.py
│ ├── synapse-user-rooms.py
│ ├── synapse-room-member-flow.py
│ └── synapse-search.py
└── references/
├── synapse-admin-api.md # endpoints used + upstream docs
├── room-health-checks.md # rule definitions
├── room-graph-pipeline.md # periodic SVG dashboard with Docker
└── safety-guide.md # destructive-operation checklistSafety
These scripts hold an admin token. Read [`references/safety-guide.md`](references/safety-guide.md) before running anything new. Highlights:
synapse-deactivate-user.pyis irreversible.synapse-migrate-room.pyenables encryption — irreversible — and removes discoverability for users outside the parent space.rooms.jsonexposes user IDs / power levels for every room. Never commit it.
License
MIT for code, CC-BY-SA-4.0 for documentation. See `LICENSE-MIT` and `LICENSE-CC-BY-SA-4.0`.
Credits
Developed and maintained by Netresearch DTT GmbH. Originally derived from the internal matrix-tools Node.js scripts; ported to Python and generalised for any Synapse homeserver.
---
Made with ❤️ for Open Source by [Netresearch](https://www.netresearch.de/)
Room graph pipeline
synapse-graph.py produces a Graphviz .dot file (and optionally an .svg via the system dot binary) showing every non-replaced room and its parent-space relationships. Edges go from a child room to its parent space; node colours and the gradient on space nodes encode the rating.
Local one-shot
python3 skills/matrix-administration/scripts/synapse-fetch-rooms.py
python3 skills/matrix-administration/scripts/synapse-graph.py --space '!home:server'
xdg-open rooms.svgdot must be installed (apt install graphviz, brew install graphviz, …). Pass --no-svg to emit only the .dot source.
Periodic dashboard (Docker)
A common deployment is a static-file container that re-runs the snapshot+render at build time and serves the resulting SVG behind a reverse proxy. The Dockerfile below is a generic version of the upstream pipeline — drop in your own homeserver URL and reverse-proxy labels.
# Builder: produce rooms.{en,de}.svg from a live snapshot
FROM python:3.13-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends graphviz \
&& rm -rf /var/lib/apt/lists/*
COPY skills/matrix-administration/scripts ./scripts
# admin token mounted as a build secret
RUN --mount=type=secret,id=matrix_token \
mkdir -p /root/.config/matrix && \
cat <<EOF > /root/.config/matrix/config.json
{
"homeserver": "${MATRIX_HOMESERVER:-https://matrix.example.com}",
"admin_token": "$(cat /run/secrets/matrix_token)"
}
EOF
RUN python3 scripts/synapse-fetch-rooms.py
RUN LANGUAGE=en python3 scripts/synapse-graph.py && mv rooms.svg rooms.en.svg
RUN LANGUAGE=de python3 scripts/synapse-graph.py && mv rooms.svg rooms.de.svg
# Runtime: any tiny static-file server
FROM ghcr.io/thedevminertv/gostatic:1.5.2
CMD ["-cache","4h","-log-requests","-compress-level=2","-spa","-index","rooms.en.svg"]
COPY --from=builder /app/rooms.*.svg /static/Build with:
echo -n "syt_admin_…" > /tmp/token
DOCKER_BUILDKIT=1 docker build --secret id=matrix_token,src=/tmp/token -t matrix-graph .Schedule a rebuild however your platform prefers (cron + docker compose build --no-cache && up -d, a CI pipeline on a timer, a Kubernetes CronJob …).
Reading the output
- Green node — all checks SUCCESS.
- Orange node — at least one WARNING, no FAIL.
- Red node — at least one FAIL.
- Spaces get a green→blue gradient when healthy, otherwise a flat orange/red.
- Edges are labelled
space childand point from a room to the space that contains it. - Node tooltip (visible in browsers / vector editors that honour SVG
titleattributes) lists every individual rating message. - Replaced rooms (those that were upgraded and tombstoned) are dropped from the graph entirely.
Room health checks
synapse-rate-rooms.py and synapse-graph.py each run every room through the same set of checks (see _lib/rating.py). Results are graded:
- ✅ SUCCESS — the rule is satisfied.
- ⚠️ WARNING — minor / advisory.
- ❌ FAIL — serious or blocking.
The overall room rating is the worst result across all applicable rules.
Why "home spaces"?
Several checks compare each room to a list of spaces you consider "home" — passed via --space '!ID:server' (repeatable) or via home_space_ids / default_space_id in the config. The skill ships no homeserver-specific data; if you do not pass any space IDs, the in-space checks are skipped (no FAIL/WARN, no SUCCESS).
Rules — non-space rooms
| Rule | Result | What it means | How to fix |
|---|---|---|---|
Is a public room | ❌ FAIL | m.room.join_rules is public — anyone on the federation can join. | Switch to restricted or invite (or use synapse-migrate-room.py). |
Joinable from our spaces | ✅ SUCCESS | Room is restricted and at least one allow entry is a home space. | — |
Not joinable from our spaces | ❌ FAIL | Room is restricted but no allow entry is a home space. | Add the relevant home space to m.room.join_rules.allow. |
In one of our spaces | ✅ SUCCESS | Some home space lists this room as m.space.child. | — |
Not in one of our spaces | ⚠️ WARNING | No home space lists this room. | synapse-add-to-space.py. |
Predecessor was in one of our spaces | ❌ FAIL | The room replaced an older one (m.room.tombstone) that was in a home space; the new room isn't. | Re-link the new room into the parent space. |
Encrypted | ✅ SUCCESS | m.room.encryption is set. | — |
Not encrypted | ⚠️ WARNING | Room transmits messages in plaintext. | Enable encryption (irreversible — see safety guide). |
Rules — spaces
| Rule | Result | What it means | How to fix |
|---|---|---|---|
Is a public space | ❌ FAIL | m.room.join_rules on the space is public. | Switch to invite or restricted. |
One of our spaces | ✅ SUCCESS | Space ID is listed in home_space_ids. | — |
Not one of our spaces | ⚠️ WARNING | Space exists on the homeserver but isn't part of your home tree. | Add it to home_space_ids if it should be, or leave it. |
Restricted joins and room version
The restricted join rule requires room version > 9 (Matrix v1.2). Older rooms cannot use it; synapse-migrate-room.py skips the join-rule step and prints a red ❌ when this happens. To upgrade a room version, use Element's "Upgrade room" UI — there is no script in this skill for room upgrades.
EN/DE phrasing
Pass --language de (or set LANGUAGE=de) on either rater script to emit German messages. Both phrasings are baked into _lib/rating.py.
Safety guide
These scripts hold a Synapse server-admin token. A bad command is visible homeserver-wide and a few are not reversible. Read this once before running anything new.
Pre-flight checklist
Before any state-changing command:
1. Confirm the homeserver URL in ~/.config/matrix/config.json — the wrong host with the right script wipes the wrong server. 2. Confirm the user/room/space ID you typed. Matrix IDs are similar; copy/paste them and read them back. 3. Run any read-only script first to verify the target exists:
python3 …/synapse-user-rooms.py '@user:server'python3 …/synapse-room-member-flow.py '!room:server'
4. For destructive operations, dry-run by hand: print the planned action to stdout (echo, python3 -c 'print(…)') and ask the operator to confirm before invoking the script.
Per-script risks
synapse-deactivate-user.py
- Calls
POST /_synapse/admin/v1/deactivate/{user_id}. - The user cannot log in again, all access tokens are revoked, the user is left from every joined room.
- With
--erase, message bodies are also redacted (GDPR right-to-erasure). - Not reversible without a database operation by a homeserver operator.
- The script prints the user's profile + joined rooms before and after, and refuses to run non-interactively without
--yes.
synapse-migrate-room.py
The hardening pipeline is partially irreversible:
| Step | Reversible? |
|---|---|
| Add to space | yes — remove the m.space.child event |
| Force-join the caller | yes — leave the room |
| Promote to PL 100 | yes — restored at the end of the pipeline |
Switch public → restricted | yes — change m.room.join_rules back |
| Enable encryption | no — Megolm cannot be turned off |
The room version must be > 9 for restricted joins; otherwise the script prints a red ❌ for that step. Switching to restricted removes discoverability for users not in the parent space.
synapse-make-admin.py / synapse-join-room.py
- Only works while some existing admin is still in the room (Synapse refuses otherwise).
- If the original owner has already left, the room is unrecoverable through this tool — Synapse explicitly forbids re-creating admin from outside.
synapse-search.py
- Read-only on the API. Risk is misinterpretation: only unencrypted messages are indexed. An empty result on an E2EE room means "search saw nothing", not "the user did not say anything". Use Element / a real client when you need plaintext from an encrypted room.
synapse-fetch-rooms.py
- Read-only, but the resulting
rooms.jsoncontains user IDs, room names, power levels and join policies for every indexed room. - Never commit it — the skill's
.gitignoredoes not cover the working directory the script is run in. - Treat the file the same as the admin token.
Token hygiene
- The admin token is equivalent to root on the homeserver. Store it only in
~/.config/matrix/config.json(or a secret manager that writes that file at startup). - Rotate it after onboarding/offboarding any user who had file-system access to a host that ran these scripts.
- If you publish a Docker image (e.g. the graph dashboard from
room-graph-pipeline.md), pass the token via--secret, never viaENVor build args.
Synapse Admin API — endpoint reference
Endpoints used by this skill, mapped to the upstream documentation.
All endpoints are reached through _lib/admin_http.py's admin_request() (paths under /_synapse/admin) or client_request() (paths under /_matrix/client/v3). Authentication is a Bearer token taken from admin_token (or, as a fallback, access_token).
Upstream docs: <https://element-hq.github.io/synapse/latest/usage/administration/admin_api/index.html>
Rooms
| Endpoint | Used by | Notes |
|---|---|---|
GET /v1/rooms?from=N&sort | synapse-fetch-rooms.py | Paginated, follow next_batch until missing. |
GET /v1/rooms/{room_id} | synapse-fetch-rooms.py, synapse-biggest-rooms.py, synapse-migrate-room.py | Returns version, name, join_rules, encryption. |
GET /v1/rooms/{room_id}/state | synapse-fetch-rooms.py | All current state events. |
POST /v1/rooms/{room_id}/make_room_admin | synapse-make-admin.py, synapse-migrate-room.py | Body: {"user_id": "@..."}. Requires another admin to still be present. |
POST /v1/join/{room_id} | synapse-join-room.py | Body: {"user_id": "@..."}. |
GET /v1/rooms/{room_id}/messages?dir=b&limit=N | (ad-hoc timeline reads) | Reads a room's timeline without joining — unlike the Client-Server /messages endpoint, which 403s for rooms the token's user isn't in. Returns chunk[]. from is optional here (the admin endpoint defaults it, unlike the CS endpoint); pass from/to only to paginate. |
Users
| Endpoint | Used by | Notes |
|---|---|---|
GET /v2/users/{user_id} | synapse-deactivate-user.py | Profile + admin status. |
GET /v2/users/{user_id}/joined_rooms | synapse-deactivate-user.py | Returns joined_rooms array. |
POST /v1/deactivate/{user_id} | synapse-deactivate-user.py | Body {"erase": true} for GDPR removal of message bodies. |
Statistics
| Endpoint | Used by | Notes |
|---|---|---|
GET /v1/statistics/database/rooms | synapse-biggest-rooms.py | Returns rooms[].estimated_size. |
Client-Server v3 (used with the admin token)
The admin user must be a member of the target room for state writes.
| Endpoint | Used by | Notes |
|---|---|---|
GET /rooms/{room_id}/state | synapse-migrate-room.py, synapse-room-member-flow.py | All current state events. |
PUT /rooms/{room_id}/state/{event_type}/{state_key} | synapse-add-to-space.py, synapse-migrate-room.py | Used for m.space.child, m.room.join_rules, m.room.encryption, m.room.power_levels. |
POST /rooms/{room_id}/join | synapse-migrate-room.py | Joins the calling user. |
GET /rooms/{room_id}/context/{event_id}?filter=...&limit=1 | synapse-room-member-flow.py | Used to recover the previous state event a leave/kick replaced. |
POST /search | synapse-search.py | Body: room-event search payload, paginated via next_batch. |
Room-ID gotcha: newer rooms have no :server suffix
Room IDs are not always !localpart:server. Room version 12+ (hash-based IDs) can be just !<hash> with no `:server` suffix — e.g. !vqqacuaPMN-dV0WHbz4ISOzOCws8HX0EWZ7UphUoiCQ. Use the room_id verbatim as returned by GET /v1/rooms / rooms.json; never append a server part.
If you do append :server to such an id, the admin room endpoints (GET /v1/rooms/{room_id}/messages, /state, …) return an empty result rather than a 404 — so it looks like "the room has no messages" when the room is fine and you simply addressed a non-existent id. Symptom: a messages fetch or synapse-search.py returns nothing for a room you know is active. Fix: copy the exact room_id from the snapshot, unmodified.
Encryption note
The synapse-search.py script uses the same Client-Server search endpoint a regular Element client does. End-to-end-encrypted messages are encrypted on the homeserver, so the search index never sees plaintext. Empty results ≠ no messages.
"""matrix-administration shared library.
Stdlib only. At the top of each script:
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import load_config, admin_request, client_request
"""
from _lib.admin_http import admin_request, client_request, quote
from _lib.colors import bold, cyan, gray, green, red, yellow
from _lib.condensing import Room, condense
from _lib.config import load_config
from _lib.pretty_bytes import pretty_bytes
from _lib.rating import RoomRating, format_rating, rate_room, rating_emoji
__all__ = [
"admin_request",
"client_request",
"quote",
"bold",
"cyan",
"gray",
"green",
"red",
"yellow",
"Room",
"condense",
"load_config",
"pretty_bytes",
"RoomRating",
"format_rating",
"rate_room",
"rating_emoji",
]
"""HTTP wrappers for the Synapse Admin API and Matrix Client-Server API.
Both admin endpoints (under ``/_synapse/admin``) and standard Matrix client
endpoints (under ``/_matrix/client/v3``) are reachable through the same HTTP
helpers. Most helpers in this module accept an admin token; a few stubs are
provided for endpoints that require client-server semantics.
Stdlib only.
"""
from __future__ import annotations
import contextlib
import json
import socket
import urllib.error
import urllib.parse
import urllib.request
_ALLOWED_SCHEMES = frozenset({"http", "https"})
def _require_http_scheme(url: str) -> None:
scheme = urllib.parse.urlparse(url).scheme
if scheme not in _ALLOWED_SCHEMES:
raise ValueError(
f"Refusing to fetch URL with scheme {scheme!r}; only http/https allowed"
)
@contextlib.contextmanager
def _prefer_ipv4():
"""Temporarily prefer IPv4 in DNS resolution (WSL2 workaround)."""
original = socket.getaddrinfo
def patched(*args, **kwargs):
results = original(*args, **kwargs)
return sorted(results, key=lambda r: r[0] != socket.AF_INET)
socket.getaddrinfo = patched
try:
yield
finally:
socket.getaddrinfo = original
_DEFAULT_TIMEOUT = 60
def _do_request(req: urllib.request.Request) -> dict:
"""Execute a request and return parsed JSON response.
The caller must have validated the URL scheme via
``_require_http_scheme`` before constructing ``req``. A 60-second
timeout is applied so a hung server does not freeze the script.
"""
_require_http_scheme(req.full_url)
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
with urllib.request.urlopen(req, timeout=_DEFAULT_TIMEOUT) as response: # noqa: S310 — scheme validated above
body = response.read().decode()
if not body:
return {}
return json.loads(body)
def _parse_http_error(e: urllib.error.HTTPError) -> dict:
error_body = e.read().decode()
try:
parsed = json.loads(error_body)
return {
"error": parsed.get("error", error_body),
"errcode": parsed.get("errcode"),
"status": e.code,
}
except json.JSONDecodeError:
return {"error": error_body, "errcode": str(e.code), "status": e.code}
def _request(url: str, method: str, token: str, data: dict | None) -> dict:
_require_http_scheme(url)
headers = {"Authorization": f"Bearer {token}"}
body = None
if data is not None:
headers["Content-Type"] = "application/json"
body = json.dumps(data).encode()
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
return _do_request(req)
except urllib.error.HTTPError as e:
return _parse_http_error(e)
except OSError as e:
if e.errno not in (101, 113):
return {"error": str(e)}
try:
with _prefer_ipv4():
req2 = urllib.request.Request(
url, data=body, headers=headers, method=method
)
return _do_request(req2)
except urllib.error.HTTPError as e2:
return _parse_http_error(e2)
except OSError as e2:
return {"error": str(e2)}
def admin_request(
config: dict, method: str, endpoint: str, data: dict | None = None
) -> dict:
"""Call a Synapse Admin API endpoint.
Args:
config: Dict with ``homeserver`` and ``admin_token`` (or ``access_token``).
method: HTTP method.
endpoint: Admin path beginning with ``/v1/...`` or ``/v2/...``
(the ``/_synapse/admin`` prefix is added automatically).
data: JSON body, if any.
Returns:
Parsed JSON response, or a dict with ``error``/``errcode`` on failure.
"""
token = config.get("admin_token") or config["access_token"]
url = f"{config['homeserver']}/_synapse/admin{endpoint}"
return _request(url, method, token, data)
def client_request(
config: dict, method: str, endpoint: str, data: dict | None = None
) -> dict:
"""Call a Matrix Client-Server v3 endpoint with the admin token.
Used for state events and other Matrix-spec calls that the admin token
can perform (the admin user must be in the room).
Args:
config: Dict with ``homeserver`` and ``admin_token`` (or ``access_token``).
method: HTTP method.
endpoint: Path beginning with ``/`` (the ``/_matrix/client/v3`` prefix
is added automatically).
data: JSON body, if any.
"""
token = config.get("admin_token") or config["access_token"]
url = f"{config['homeserver']}/_matrix/client/v3{endpoint}"
return _request(url, method, token, data)
def quote(value: str) -> str:
"""URL-encode a path segment (room IDs, user IDs, aliases)."""
return urllib.parse.quote(value, safe="")
"""Minimal ANSI colour helpers.
Disabled automatically when stdout is not a TTY or ``NO_COLOR`` is set.
"""
from __future__ import annotations
import os
import sys
def _enabled() -> bool:
if os.environ.get("NO_COLOR"):
return False
return sys.stdout.isatty()
_CODES = {
"reset": "\x1b[0m",
"bold": "\x1b[1m",
"gray": "\x1b[90m",
"red": "\x1b[31m",
"green": "\x1b[32m",
"yellow": "\x1b[33m",
"blue": "\x1b[34m",
"magenta": "\x1b[35m",
"cyan": "\x1b[36m",
}
def _wrap(text: str, name: str) -> str:
if not _enabled():
return text
return f"{_CODES[name]}{text}{_CODES['reset']}"
def bold(text: str) -> str:
return _wrap(text, "bold")
def gray(text: str) -> str:
return _wrap(text, "gray")
def red(text: str) -> str:
return _wrap(text, "red")
def green(text: str) -> str:
return _wrap(text, "green")
def yellow(text: str) -> str:
return _wrap(text, "yellow")
def blue(text: str) -> str:
return _wrap(text, "blue")
def cyan(text: str) -> str:
return _wrap(text, "cyan")
"""Reduce the verbose ``rooms.json`` snapshot into a small ``Room`` graph.
Mirrors the original ``lib/condensing.mjs`` from the matrix-tools project
but is fully Python/stdlib. See ``synapse-fetch-rooms.py`` for the input
format (a list of ``{"room": …, "states": [state events …]}`` entries).
Stdlib only.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional
# A join policy is one of:
# "public"
# "invite-only"
# "unknown"
# ("in-space", [allow_room_id, …]) — room version 9+ restricted joins
JoinPolicy = object # documented above; kept as object for simplicity
@dataclass
class Room:
id: str
name: str
version: int
creator: Optional[str] = None
join_policy: JoinPolicy = "public"
space_children: list[str] = field(default_factory=list)
permissions: dict[str, int] = field(default_factory=dict)
members: dict[str, datetime] = field(default_factory=dict)
is_encrypted: bool = False
is_space: bool = False
is_replaced: bool = False
predecessor: Optional[str] = None
def condense(data: list[dict]) -> dict[str, Room]:
"""Convert a list of fetched rooms with their state events into a dict
of ``Room`` objects keyed by room ID.
Rooms with no human-readable name are skipped (they are usually direct
messages or internal rooms with no meaningful identity).
"""
rooms: dict[str, Room] = {}
for entry in data:
room = entry.get("room", {})
states = entry.get("states", [])
name = room.get("name")
if not name:
continue
is_space = room.get("room_type") == "m.space"
try:
version = int(room.get("version", 0))
except (TypeError, ValueError):
version = 0
r = Room(
id=room["room_id"],
name=name,
version=version,
is_encrypted=room.get("encryption") is not None,
is_space=is_space,
)
rooms[r.id] = r
for state in states:
stype = state.get("type")
content = state.get("content", {}) or {}
if stype == "m.room.create":
r.creator = content.get("creator") or state.get("sender")
pred = content.get("predecessor")
if isinstance(pred, dict) and pred.get("room_id"):
r.predecessor = pred["room_id"]
elif stype == "m.room.tombstone" and content.get("replacement_room"):
r.is_replaced = True
elif stype == "m.space.child" and content:
r.space_children.append(state.get("state_key", ""))
elif stype == "m.room.join_rules":
rule = content.get("join_rule")
if rule == "public":
r.join_policy = "public"
elif rule == "invite":
r.join_policy = "invite-only"
elif rule == "restricted":
allow = content.get("allow") or []
room_ids = [
a["room_id"]
for a in allow
if isinstance(a, dict)
and a.get("type") == "m.room_membership"
and a.get("room_id")
]
r.join_policy = ("in-space", room_ids)
else:
r.join_policy = "unknown"
elif stype == "m.room.power_levels":
users = content.get("users") or {}
for uid, level in users.items():
try:
r.permissions[uid] = int(level)
except (TypeError, ValueError):
continue
elif (
stype == "m.room.member"
and content.get("membership") == "join"
and state.get("state_key")
):
ts = state.get("origin_server_ts")
if isinstance(ts, (int, float)):
r.members[state["state_key"]] = datetime.fromtimestamp(
ts / 1000, tz=timezone.utc
)
return rooms
"""Configuration loading for Synapse Admin scripts.
Reuses ``~/.config/matrix/config.json`` (the same file the
``matrix-communication`` skill reads). Admin scripts require an admin-level
token and accept the following optional fields in addition to the standard
ones:
- ``admin_token``: a Matrix access token for a user with Synapse server-admin
rights. Falls back to ``access_token`` if absent.
- ``room_filter``: optional server-suffix filter applied by
``synapse-fetch-rooms.py`` (e.g. ``":example.com"``). Empty/missing means
no filter.
- ``default_space_id``: optional fallback space ID used by space-related
scripts when no CLI argument or ``MATRIX_SPACE_ID`` env var is given.
Stdlib only.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
def get_config_path() -> Path:
"""Return the Matrix configuration file path.
Respects ``XDG_CONFIG_HOME`` and falls back to ``~/.config``. Same
resolution as the matrix-communication skill so a single config file
is shared by both.
"""
xdg_config = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
return Path(xdg_config) / "matrix" / "config.json"
def load_config(require_admin: bool = True) -> dict:
"""Load Matrix config from ``$XDG_CONFIG_HOME/matrix/config.json``.
Args:
require_admin: If True (the default), require an admin token to be
present (either as ``admin_token`` or ``access_token``).
Returns:
Parsed config dict.
Exits with a helpful message if the config is missing or incomplete.
"""
config_path = get_config_path()
if not config_path.exists():
print(f"Error: Config file not found: {config_path}", file=sys.stderr)
print("Create it with at least:", file=sys.stderr)
example = {
"homeserver": "https://matrix.example.com",
"admin_token": "syt_...",
}
print(json.dumps(example, indent=2), file=sys.stderr)
sys.exit(1)
with open(config_path) as f:
config = json.load(f)
if "homeserver" not in config:
print("Error: config missing required field: homeserver", file=sys.stderr)
sys.exit(1)
if require_admin and not (config.get("admin_token") or config.get("access_token")):
print(
"Error: config missing admin token. Set 'admin_token' (preferred) "
"or 'access_token' to a token belonging to a Synapse server admin.",
file=sys.stderr,
)
sys.exit(1)
return config
"""Human-readable byte-size formatter.
Decimal units by default (``kB``, ``MB``, ``GB`` …); pass ``binary=True``
for IEC units (``KiB``, ``MiB`` …). Stdlib only.
"""
from __future__ import annotations
_BYTE_UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
_BIBYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
def pretty_bytes(number: float, binary: bool = False, space: bool = True) -> str:
"""Format ``number`` of bytes with a human-readable unit.
Examples
--------
>>> pretty_bytes(0)
'0 B'
>>> pretty_bytes(1500)
'1.50 kB'
>>> pretty_bytes(1500, binary=True)
'1.46 KiB'
"""
if number != number or number in (float("inf"), float("-inf")):
raise ValueError(f"Expected a finite number, got: {number!r}")
units = _BIBYTE_UNITS if binary else _BYTE_UNITS
sep = " " if space else ""
sign = "-" if number < 0 else ""
n = abs(number)
if n < 1:
return f"{sign}{n:.0f}{sep}{units[0]}"
base = 1024 if binary else 1000
exponent = 0
while n >= base and exponent < len(units) - 1:
n /= base
exponent += 1
if n >= 100:
formatted = f"{n:.0f}"
elif n >= 10:
formatted = f"{n:.1f}"
else:
formatted = f"{n:.2f}"
return f"{sign}{formatted}{sep}{units[exponent]}"
"""Room health-check rules.
Each room is rated against a set of policies and earns one of three levels:
- ``SUCCESS``: rule satisfied
- ``WARNING``: minor / advisory issue
- ``FAIL``: serious / blocking issue
Both English and German phrasings are provided. The "is the room reachable
from one of our spaces" check requires a list of *home* space IDs to be
passed in by the caller — the skill itself ships no homeserver-specific data.
Stdlib only.
"""
from __future__ import annotations
from enum import IntEnum
from typing import Optional
from _lib.condensing import Room
class RoomRating(IntEnum):
SUCCESS = 0
WARNING = 1
FAIL = 2
def rating_emoji(rating: RoomRating) -> str:
return {RoomRating.SUCCESS: "✅", RoomRating.WARNING: "⚠️", RoomRating.FAIL: "❌"}[
rating
]
def format_rating(item: tuple[RoomRating, str]) -> str:
rating, message = item
return f"{rating_emoji(rating)} {message}"
_T = {
"room": {
"public": {
"en": "Is a public room",
"de": "Ist ein öffentlicher Raum",
},
"encryption": {
"yes": {"en": "Encrypted", "de": "Verschlüsselt"},
"no": {"en": "Not encrypted", "de": "Nicht verschlüsselt"},
},
"joinable-from-our-spaces": {
"yes": {
"en": "Joinable from our spaces",
"de": "Von unseren Spaces aus beitretbar",
},
"no": {
"en": "Not joinable from our spaces",
"de": "Nicht von unseren Spaces aus beitretbar",
},
},
"in-one-of-our-spaces": {
"yes": {"en": "In one of our spaces", "de": "In einem unserer Spaces"},
"no": {
"en": "Not in one of our spaces",
"de": "Nicht in einem unserer Spaces",
},
"predecessor-was": {
"en": "Predecessor was in one of our spaces",
"de": "Vorgänger war in einem unserer Spaces",
},
},
},
"space": {
"public": {
"en": "Is a public space",
"de": "Ist ein öffentlicher Space",
},
"our": {
"yes": {"en": "One of our spaces", "de": "Einer unserer Spaces"},
"no": {"en": "Not one of our spaces", "de": "Nicht einer unserer Spaces"},
},
},
}
def rate_room(
r: Room,
rooms: dict[str, Room],
home_space_ids: Optional[list[str]] = None,
language: str = "en",
) -> tuple[RoomRating, list[tuple[RoomRating, str]]]:
"""Run health checks on a single room.
Args:
r: The room to rate.
rooms: All rooms in the snapshot (used to look up parent spaces and
predecessors).
home_space_ids: List of "your" space IDs. Rooms reachable from
(or members of) one of these are treated as in-space. When
``None`` or empty, the in-space checks degrade to "no opinion".
language: ``"en"`` or ``"de"``.
Returns:
A tuple ``(overall, messages)``: the worst rating across all checks,
and the per-check ``(rating, message)`` list.
"""
if language not in ("en", "de"):
language = "en"
home = set(home_space_ids or [])
messages: list[tuple[RoomRating, str]] = []
def add(rating: RoomRating, message: str) -> None:
messages.append((rating, message))
if r.is_space:
if r.join_policy == "public":
add(RoomRating.FAIL, _T["space"]["public"][language])
if isinstance(r.join_policy, tuple) and r.join_policy[0] == "in-space":
allow_ids = r.join_policy[1]
if home:
if not any(rid in home for rid in allow_ids):
add(
RoomRating.FAIL,
_T["room"]["joinable-from-our-spaces"]["no"][language],
)
else:
add(
RoomRating.SUCCESS,
_T["room"]["joinable-from-our-spaces"]["yes"][language],
)
if home:
if r.id not in home:
add(RoomRating.WARNING, _T["space"]["our"]["no"][language])
else:
add(RoomRating.SUCCESS, _T["space"]["our"]["yes"][language])
else:
if r.join_policy == "public":
add(RoomRating.FAIL, _T["room"]["public"][language])
if (
r.version > 9
and isinstance(r.join_policy, tuple)
and r.join_policy[0] == "in-space"
):
allow_ids = r.join_policy[1]
if home:
if not any(rid in home for rid in allow_ids):
add(
RoomRating.FAIL,
_T["room"]["joinable-from-our-spaces"]["no"][language],
)
else:
add(
RoomRating.SUCCESS,
_T["room"]["joinable-from-our-spaces"]["yes"][language],
)
if home:
if not _is_in_one_of(r, rooms, home):
add(
RoomRating.WARNING,
_T["room"]["in-one-of-our-spaces"]["no"][language],
)
if r.predecessor:
pred = rooms.get(r.predecessor)
if pred and _is_in_one_of(pred, rooms, home):
add(
RoomRating.FAIL,
_T["room"]["in-one-of-our-spaces"]["predecessor-was"][
language
],
)
else:
add(
RoomRating.SUCCESS,
_T["room"]["in-one-of-our-spaces"]["yes"][language],
)
if not r.is_encrypted:
add(RoomRating.WARNING, _T["room"]["encryption"]["no"][language])
else:
add(RoomRating.SUCCESS, _T["room"]["encryption"]["yes"][language])
overall = max((rating for rating, _ in messages), default=RoomRating.SUCCESS)
return overall, messages
def _is_in_one_of(r: Room, rooms: dict[str, Room], home: set[str]) -> bool:
"""True iff *some* parent space of ``r`` is in ``home``."""
for s in rooms.values():
if r.id in s.space_children and s.id in home:
return True
return False
#!/usr/bin/env python3
"""Link a room into a space.
Sends an ``m.space.child`` state event on the space with the room as the
state key. The admin token must belong to a user that has permission to
send state events in the space (typically a space admin).
Usage:
synapse-add-to-space.py <ROOM_ID> [SPACE_ID]
Falls back to ``$MATRIX_SPACE_ID`` and then to ``default_space_id`` from
the config when ``SPACE_ID`` is omitted.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import client_request, load_config, quote
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("room_id")
parser.add_argument("space_id", nargs="?", default=None)
parser.add_argument(
"--via",
action="append",
default=None,
help="Server name to include in 'via'. Repeatable; defaults to the "
"homeserver hostname.",
)
parser.add_argument("--suggested", action="store_true")
args = parser.parse_args()
config = load_config()
space_id = (
args.space_id
or os.environ.get("MATRIX_SPACE_ID")
or config.get("default_space_id")
)
if not space_id:
print(
"Error: SPACE_ID required (positional, $MATRIX_SPACE_ID, or "
"'default_space_id' in config).",
file=sys.stderr,
)
return 2
via = args.via
if not via:
from urllib.parse import urlparse
host = urlparse(config["homeserver"]).hostname
via = [host] if host else []
body = {"via": via, "suggested": args.suggested}
endpoint = f"/rooms/{quote(space_id)}/state/m.space.child/{quote(args.room_id)}"
result = client_request(config, "PUT", endpoint, body)
print(json.dumps(result, indent=2))
return 0 if "error" not in result else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Print the largest rooms on the homeserver by Synapse-estimated DB size.
Calls ``GET /_synapse/admin/v1/statistics/database/rooms`` and looks up the
display name for each result. Limit defaults to 10.
Usage:
synapse-biggest-rooms.py [--limit 10]
"""
from __future__ import annotations
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import admin_request, load_config, pretty_bytes
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("-n", "--limit", type=int, default=10)
args = parser.parse_args()
config = load_config()
result = admin_request(config, "GET", "/v1/statistics/database/rooms")
if "error" in result:
print(f"Error: {result['error']}", file=sys.stderr)
return 1
rooms = result.get("rooms", []) or []
rooms.sort(key=lambda r: r.get("estimated_size", 0), reverse=True)
rooms = rooms[: args.limit]
rows = []
for r in rooms:
info = admin_request(config, "GET", f"/v1/rooms/{r['room_id']}")
name = info.get("name") if isinstance(info, dict) else None
rows.append(
{
"room_id": r["room_id"],
"name": name,
"size": pretty_bytes(r.get("estimated_size", 0)),
}
)
width_id = max((len(row["room_id"]) for row in rows), default=8)
width_size = max((len(row["size"]) for row in rows), default=4)
print(f"{'room_id'.ljust(width_id)} {'size'.rjust(width_size)} name")
for row in rows:
name = row["name"] or "-"
print(
f"{row['room_id'].ljust(width_id)} {row['size'].rjust(width_size)} {name}"
)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Deactivate a Matrix user.
Calls ``POST /_synapse/admin/v1/deactivate/{user_id}``. The user is
unable to log in again afterwards and joined-rooms membership is severed.
**Not reversible** without direct database intervention.
Prints the user's profile and joined-rooms list before and after, so the
console transcript serves as an audit trail.
Usage:
synapse-deactivate-user.py <USER_ID> [--erase] [--yes]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import admin_request, bold, load_config, quote, red, yellow
def _user_info(config: dict, user_id: str) -> dict:
return admin_request(config, "GET", f"/v2/users/{quote(user_id)}")
def _joined_rooms(config: dict, user_id: str) -> list[str]:
res = admin_request(config, "GET", f"/v2/users/{quote(user_id)}/joined_rooms")
return res.get("joined_rooms", []) if isinstance(res, dict) else []
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("user_id")
parser.add_argument(
"--erase",
action="store_true",
help="GDPR erase: also remove the user's messages from rooms.",
)
parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation.")
args = parser.parse_args()
config = load_config()
print(bold("Before:"))
print(json.dumps(_user_info(config, args.user_id), indent=2))
print(json.dumps(_joined_rooms(config, args.user_id), indent=2))
if not args.yes:
if sys.stdin.isatty():
print(
bold(
red(f"⚠ About to deactivate {args.user_id}. This cannot be undone.")
)
)
try:
answer = input("Type 'YES' to continue: ").strip()
except EOFError:
answer = ""
if answer != "YES":
print(yellow("Aborted."))
return 1
else:
print(
bold(red("Refusing to run non-interactively without --yes.")),
file=sys.stderr,
)
return 2
body = {"erase": True} if args.erase else None
res = admin_request(config, "POST", f"/v1/deactivate/{quote(args.user_id)}", body)
print(bold("Deactivate response:"))
print(json.dumps(res, indent=2))
print(bold("After:"))
print(json.dumps(_user_info(config, args.user_id), indent=2))
print(json.dumps(_joined_rooms(config, args.user_id), indent=2))
return 0 if "error" not in res else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Snapshot every visible room on a Synapse homeserver.
Pages through ``GET /_synapse/admin/v1/rooms`` and, for each room, fetches
its full state via ``/v1/rooms/{room_id}/state``. Writes the result as a
JSON list of ``{"room": …, "states": […]}`` entries — the format consumed
by the other scripts (rating, graph, member listings).
Apply an optional server-suffix filter via ``--server`` (e.g.
``--server :example.com``) or the ``room_filter`` config field. Without a
filter every visible room is included.
Usage:
synapse-fetch-rooms.py [--output rooms.json] [--server :example.com]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import admin_request, load_config, quote
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument(
"-o", "--output", default="rooms.json", help="Output path (default rooms.json)"
)
parser.add_argument(
"--server",
default=None,
help="Optional server suffix to filter on (e.g. ':example.com'). "
"Defaults to the 'room_filter' config field.",
)
args = parser.parse_args()
config = load_config()
suffix = args.server if args.server is not None else config.get("room_filter", "")
from_token: int | str = 0
rooms: list[dict] = []
while True:
# `order_by=name` is a documented Synapse query parameter; the
# original tool used a bare `&sort` flag that produced an
# empty-valued query parameter (parsed inconsistently by some
# proxies). See:
# https://element-hq.github.io/synapse/latest/admin_api/rooms.html
result = admin_request(
config,
"GET",
f"/v1/rooms?from={quote(str(from_token))}&order_by=name",
)
if "error" in result:
print(f"Error fetching rooms: {result['error']}", file=sys.stderr)
return 1
page = result.get("rooms", []) or []
if suffix:
page = [r for r in page if r.get("room_id", "").endswith(suffix)]
for room in page:
state_res = admin_request(
config, "GET", f"/v1/rooms/{room['room_id']}/state"
)
if "error" in state_res:
print(
f"Warning: failed to fetch state for {room['room_id']}: "
f"{state_res['error']}",
file=sys.stderr,
)
continue
states = sorted(
state_res.get("state", []) or [],
key=lambda s: s.get("origin_server_ts", 0),
)
rooms.append({"room": room, "states": states})
next_batch = result.get("next_batch")
if next_batch is None:
break
from_token = next_batch
with open(args.output, "w") as f:
json.dump(rooms, f, indent=2)
print(f"Wrote {len(rooms)} rooms to {args.output}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Render a snapshot of rooms as a Graphviz graph.
Reads ``rooms.json`` (produced by ``synapse-fetch-rooms.py``), writes
``rooms.dot`` (Graphviz source) and — if the ``dot`` binary is available —
``rooms.svg``.
Each room is a node coloured by its rating (green/orange/red). Spaces are
drawn with a green/blue gradient when healthy. Edges go from child rooms
to their parent space.
Usage:
synapse-graph.py [--input rooms.json] [--dot rooms.dot] [--svg rooms.svg] \\
[--language en|de] [--space '!ID:server' …] [--no-svg]
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import (
Room,
RoomRating,
condense,
format_rating,
load_config,
rate_room,
)
_COLORS = {
"blue": "#2222ff",
"bright_blue": "#7777ff",
"red": "#ff2222",
"bright_red": "#ff7777",
"green": "#22cc22",
"bright_green": "#77ff77",
"gray": "#222222",
"orange": "#ff8800",
"bright_orange": "#ffaa22",
}
def _enc(value: str) -> str:
"""Escape a string for use inside a Graphviz DOT double-quoted literal.
DOT treats backslash + character as an escape sequence (``\\n``,
``\\l``, ``\\r``, ``\\"``). We escape backslashes first, then quotes,
then newlines/CR — order matters so the backslash escape doesn't
double-escape itself.
"""
if value is None:
return ""
return (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
)
def _join_policy_text(r: Room, rooms: dict[str, Room]) -> str:
if isinstance(r.join_policy, tuple) and r.join_policy[0] == "in-space":
names = [rooms[rid].name for rid in r.join_policy[1] if rid in rooms]
return "members of " + ", ".join(names) if names else "members of (unknown)"
if isinstance(r.join_policy, str):
return r.join_policy
return "unknown"
def _icon(r: Room) -> str:
if r.join_policy == "public":
return "🌐"
if r.join_policy == "invite-only":
return "🔒"
return "🚀"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("-i", "--input", default="rooms.json")
parser.add_argument("--dot", default="rooms.dot")
parser.add_argument("--svg", default="rooms.svg")
parser.add_argument("-l", "--language", default=None, choices=["en", "de"])
parser.add_argument(
"-s",
"--space",
action="append",
default=None,
help="Space ID treated as 'home'. Repeatable.",
)
parser.add_argument(
"--no-svg", action="store_true", help="Skip the dot → SVG step."
)
args = parser.parse_args()
config = load_config(require_admin=False)
language = args.language or os.environ.get("LANGUAGE", "en")
if language not in ("en", "de"):
language = "en"
home_spaces: list[str] = list(args.space or [])
if not home_spaces:
cfg_home = config.get("home_space_ids") or []
if isinstance(cfg_home, list):
home_spaces.extend(cfg_home)
if config.get("default_space_id"):
home_spaces.append(config["default_space_id"])
with open(args.input) as f:
data = json.load(f)
rooms = condense(data)
lines: list[str] = ["digraph G {", ' graph [rankdir="LR"];']
for r in rooms.values():
if r.is_replaced:
continue
score, violations = rate_room(r, rooms, home_spaces, language)
if score == RoomRating.SUCCESS:
color = _COLORS["green"]
fill = (
f"{_COLORS['bright_green']}:{_COLORS['bright_blue']}"
if r.is_space
else _COLORS["bright_green"]
)
elif score == RoomRating.WARNING:
color = _COLORS["orange"]
fill = _COLORS["bright_orange"]
else:
color = _COLORS["red"]
fill = _COLORS["bright_red"]
creator = (r.creator or "unknown").split(":")[0]
label = f"{_icon(r)} {_enc(r.name)}\\n(v{r.version}, {_join_policy_text(r, rooms)}, by {_enc(creator)})"
tooltip = "\\n".join(format_rating(m) for m in violations)
lines.append(
f' "{_enc(r.id)}" [label="{label}", style=filled, '
f'color="{color}", fontcolor="black", fillcolor="{fill}", '
f'tooltip="{_enc(tooltip)}", shape=rectangle, penwidth=3];'
)
for child_id in r.space_children:
child = rooms.get(child_id)
if not child or child.is_replaced:
continue
lines.append(
f' "{_enc(child_id)}" -> "{_enc(r.id)}" [label="space child"];'
)
lines.append("}")
with open(args.dot, "w") as f:
f.write("\n".join(lines) + "\n")
if args.no_svg:
print(f"Wrote {args.dot}")
return 0
if shutil.which("dot") is None:
print(
f"Wrote {args.dot}. Install Graphviz ('dot') to render the SVG, or pass --no-svg.",
file=sys.stderr,
)
return 0
try:
subprocess.run(
["dot", "-Tsvg", args.dot, "-o", args.svg],
check=True,
)
except subprocess.CalledProcessError as e:
print(f"dot failed: {e}", file=sys.stderr)
return 1
print(f"Wrote {args.dot} and {args.svg}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Force-join a user into a room using the Synapse Admin API.
Calls ``POST /_synapse/admin/v1/join/{room_id}`` with the target user.
At least one user that the admin can impersonate (or the admin themselves)
must already be a member of the room — otherwise Synapse refuses.
Usage:
synapse-join-room.py <ROOM_ID> [USER_ID]
Falls back to ``$MATRIX_USER_ID`` when ``USER_ID`` is omitted.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import admin_request, load_config
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("room_id")
parser.add_argument("user_id", nargs="?", default=None)
args = parser.parse_args()
config = load_config()
user_id = args.user_id or os.environ.get("MATRIX_USER_ID")
if not user_id:
print(
"Error: USER_ID required (positional argument or $MATRIX_USER_ID).",
file=sys.stderr,
)
return 2
result = admin_request(
config, "POST", f"/v1/join/{args.room_id}", {"user_id": user_id}
)
print(json.dumps(result, indent=2))
return 0 if "error" not in result else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Promote a user to power-level 100 in a room.
Calls ``POST /_synapse/admin/v1/rooms/{room_id}/make_room_admin``.
Synapse only lets this succeed while at least one existing room admin is
still a member. If the original owner has left, the room is unrecoverable
through this tool.
Usage:
synapse-make-admin.py <ROOM_ID> [USER_ID]
Falls back to ``$MATRIX_USER_ID`` when ``USER_ID`` is omitted.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import admin_request, load_config
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("room_id")
parser.add_argument("user_id", nargs="?", default=None)
args = parser.parse_args()
config = load_config()
user_id = args.user_id or os.environ.get("MATRIX_USER_ID")
if not user_id:
print(
"Error: USER_ID required (positional argument or $MATRIX_USER_ID).",
file=sys.stderr,
)
return 2
result = admin_request(
config,
"POST",
f"/v1/rooms/{args.room_id}/make_room_admin",
{"user_id": user_id},
)
print(json.dumps(result, indent=2))
return 0 if "error" not in result else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Harden a room: link it into a space, restrict joins, enable encryption.
Multi-step operation:
1. Add the room to the target space (``m.space.child`` state event), unless
already present.
2. Force-join the calling user.
3. If the user has no explicit power level, temporarily promote them to
admin (PL 100).
4. If the room is currently public *and* its room-version supports
restricted joins (>9), switch ``m.room.join_rules`` to ``restricted`` so
only members of the parent space can join.
5. Enable Megolm encryption (``m.room.encryption``) if not already enabled.
6. Restore the user's original power level — runs in a ``finally`` block
and on SIGINT/SIGTERM, so a crash or Ctrl-C does not leave the user
with elevated permissions.
WARNING: enabling encryption is irreversible. Restricted joins remove
discoverability for users outside the space.
Usage:
synapse-migrate-room.py <ROOM_ID> [USER_ID] [SPACE_ID]
Falls back to ``$MATRIX_USER_ID`` / ``$MATRIX_SPACE_ID`` /
``default_space_id``.
"""
from __future__ import annotations
import argparse
import os
import signal
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import (
admin_request,
bold,
client_request,
green,
load_config,
quote,
red,
yellow,
)
def _state(config: dict, room_id: str) -> list[dict]:
res = client_request(config, "GET", f"/rooms/{quote(room_id)}/state")
if isinstance(res, list):
return res
return res.get("state", []) or []
def _put_state(
config: dict,
room_id: str,
event_type: str,
state_key: str,
body: dict,
) -> dict:
endpoint = f"/rooms/{quote(room_id)}/state/{quote(event_type)}/{quote(state_key)}"
return client_request(config, "PUT", endpoint, body)
def _join(config: dict, room_id: str) -> dict:
return client_request(config, "POST", f"/rooms/{quote(room_id)}/join", {})
def _restore_power_level(
config: dict,
room_id: str,
user_id: str,
pl_content: dict,
previous_level: int | None,
) -> None:
"""Restore ``user_id``'s entry in the room's ``m.room.power_levels``.
If ``previous_level`` is ``None`` the user originally had no explicit
entry and ``users[user_id]`` is removed (so the user falls back to
``users_default``). Otherwise the entry is set to ``previous_level``.
Best-effort: errors are reported but never raised.
"""
if not pl_content:
return
users = dict(pl_content.get("users") or {})
if previous_level is None:
if user_id not in users:
return
print(bold(yellow(f"⚠ Removing explicit PL entry for {user_id}")))
users.pop(user_id, None)
else:
if users.get(user_id) == previous_level:
return
print(bold(yellow(f"⚠ Restoring {user_id} to power level {previous_level}")))
users[user_id] = previous_level
new_pl = dict(pl_content)
new_pl["users"] = users
res = _put_state(config, room_id, "m.room.power_levels", "", new_pl)
if "error" in res:
print(red(f"✗ Failed to restore power level: {res['error']}"), file=sys.stderr)
else:
print(bold(green("✓ Power levels restored")))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("room_id")
parser.add_argument("user_id", nargs="?", default=None)
parser.add_argument("space_id", nargs="?", default=None)
args = parser.parse_args()
config = load_config()
user_id = args.user_id or os.environ.get("MATRIX_USER_ID")
space_id = (
args.space_id
or os.environ.get("MATRIX_SPACE_ID")
or config.get("default_space_id")
)
if not user_id:
print("Error: USER_ID required.", file=sys.stderr)
return 2
if not space_id:
print(
"Error: SPACE_ID required (positional, $MATRIX_SPACE_ID, or "
"'default_space_id' in config).",
file=sys.stderr,
)
return 2
from urllib.parse import urlparse
via_host = urlparse(config["homeserver"]).hostname
via = [via_host] if via_host else []
# 1. Add to space if not already a child.
space_state = _state(config, space_id)
already_child = any(
s.get("type") == "m.space.child"
and s.get("state_key") == args.room_id
and (s.get("content") or {})
for s in space_state
)
if already_child:
print(bold(green("✓ Room already in space")))
else:
print(bold(yellow("⚠ Adding room to space")))
res = _put_state(
config,
space_id,
"m.space.child",
args.room_id,
{"via": via, "suggested": False},
)
if "error" in res:
print(red(f"✗ Failed to add to space: {res['error']}"), file=sys.stderr)
return 1
print(bold(green("✓ Room added to space")))
# 2. Join.
join_res = _join(config, args.room_id)
if "error" in join_res:
print(red(f"⚠ Join: {join_res['error']}"), file=sys.stderr)
else:
print(bold(green("✓ Joined")))
# Inspect target room (needed for power-level snapshot before any change).
room_info = admin_request(config, "GET", f"/v1/rooms/{args.room_id}")
if "error" in room_info:
print(red(f"✗ {room_info['error']}"), file=sys.stderr)
return 1
room_state = _state(config, args.room_id)
pl_event = next(
(s for s in room_state if s.get("type") == "m.room.power_levels"), None
)
pl_content = (pl_event or {}).get("content", {}) or {}
users = dict(pl_content.get("users") or {})
users_default = pl_content.get("users_default", 0)
# `previous_level` retains its `None` if the user had no explicit entry.
previous_level: int | None = users.get(user_id)
had_explicit_entry = user_id in users
promoted = False
def _try_promote() -> bool:
res = admin_request(
config,
"POST",
f"/v1/rooms/{args.room_id}/make_room_admin",
{"user_id": user_id},
)
if "error" in res:
print(red(f"✗ Promote failed: {res['error']}"), file=sys.stderr)
return False
return True
if not had_explicit_entry:
print(
bold(
yellow(
f"⚠ {user_id} has no explicit power level (default {users_default}); "
f"will restore after operations"
)
)
)
promoted = _try_promote()
elif previous_level == 100:
print(bold(green("✓ User already has maximum power level")))
else:
print(
bold(
green(
f"✓ User has power level {previous_level}; will restore after operations"
)
)
)
promoted = _try_promote()
if not promoted and not had_explicit_entry and previous_level is None:
# We never managed to elevate; subsequent state writes will likely
# fail. Bail out cleanly without touching power levels.
print(
red("✗ Could not elevate user; skipping the rest of the pipeline."),
file=sys.stderr,
)
return 1
# Install a SIGINT/SIGTERM handler so Ctrl-C still restores the power
# level before exiting.
def _signal_handler(signum, _frame):
if promoted:
_restore_power_level(
config, args.room_id, user_id, pl_content, previous_level
)
sys.exit(128 + signum)
if promoted:
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
try:
# 3. Restrict joins if currently public.
join_rules = (room_info.get("join_rules") or "").lower()
try:
version = int(room_info.get("version") or 0)
except (TypeError, ValueError):
version = 0
if join_rules == "public":
if version > 9:
print(bold(yellow("⚠ Setting join rules to restricted")))
res = _put_state(
config,
args.room_id,
"m.room.join_rules",
"",
{
"join_rule": "restricted",
"allow": [{"type": "m.room_membership", "room_id": space_id}],
},
)
if "error" in res:
print(red(f"✗ Failed: {res['error']}"), file=sys.stderr)
else:
print(bold(green("✓ Join rules set to restricted")))
else:
print(
bold(
red(
f"✗ Room version {version} is too low for restricted joins; "
"skipping"
)
)
)
else:
print(bold(green("✓ Join rules already restricted (or not public)")))
# 4. Encrypt if not already.
if not room_info.get("encryption"):
print(bold(yellow("⚠ Enabling encryption")))
res = _put_state(
config,
args.room_id,
"m.room.encryption",
"",
{"algorithm": "m.megolm.v1.aes-sha2"},
)
if "error" in res:
print(red(f"✗ Failed: {res['error']}"), file=sys.stderr)
else:
print(bold(green("✓ Encryption enabled")))
else:
print(bold(green("✓ Encryption already enabled")))
finally:
# 5. Always restore the previous power level if we promoted the user,
# even on exception.
if promoted:
_restore_power_level(
config, args.room_id, user_id, pl_content, previous_level
)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Rate every room in a snapshot and print the failing ones.
Reads ``rooms.json`` (produced by ``synapse-fetch-rooms.py``), runs the
health-check rules from ``_lib/rating.py`` against every non-replaced room,
and prints two formatted lists of rooms with at least one ``FAIL`` finding:
1. Jira wiki markup
2. Markdown
Use ``--language de`` for German phrasing. Pass ``--space`` one or more
times (or set ``default_space_id``/``home_space_ids`` in the config) to
enable the "is in one of our spaces" checks; without it those checks are
skipped.
Usage:
synapse-rate-rooms.py [--input rooms.json] [--language en|de] \\
[--space '!ID:server' …]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import (
RoomRating,
condense,
format_rating,
load_config,
rate_room,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("-i", "--input", default="rooms.json")
parser.add_argument("-l", "--language", default=None, choices=["en", "de"])
parser.add_argument(
"-s",
"--space",
action="append",
default=None,
help="Space ID treated as 'home'. Repeatable.",
)
args = parser.parse_args()
config = load_config(require_admin=False)
language = args.language or os.environ.get("LANGUAGE", "en")
if language not in ("en", "de"):
language = "en"
home_spaces: list[str] = list(args.space or [])
if not home_spaces:
cfg_home = config.get("home_space_ids") or []
if isinstance(cfg_home, list):
home_spaces.extend(cfg_home)
if config.get("default_space_id"):
home_spaces.append(config["default_space_id"])
with open(args.input) as f:
data = json.load(f)
rooms = condense(data)
flagged = []
for r in rooms.values():
if r.is_replaced:
continue
overall, messages = rate_room(r, rooms, home_spaces, language)
ratings = [m for m in messages if m[0] != RoomRating.SUCCESS]
if any(rating == RoomRating.FAIL for rating, _ in ratings):
flagged.append((r, ratings))
flagged.sort(key=lambda pair: pair[0].name.strip().lower())
def render(formatter):
return "\n".join(formatter(r, ratings) for r, ratings in flagged)
def jira(r, ratings):
head = f"{{task}}{r.name.strip()} (v{r.version}){{task}}"
body = "\n".join(f"** {format_rating(m)}" for m in ratings)
return f"{head}\n{body}" if body else head
def markdown(r, ratings):
head = f"- {r.name.strip()} (v{r.version})"
body = "\n".join(f" {format_rating(m)}" for m in ratings)
return f"{head}\n{body}" if body else head
print(render(jira))
print("\n\n")
print(render(markdown))
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Reconstruct the join/leave timeline of a room.
Walks the room's current state events (``GET .../rooms/{room_id}/state``)
and, for each non-join membership event, retrieves the event it replaced
to recover the original join timestamp. Output is sorted chronologically.
If a ``--server`` suffix is given (or ``room_filter`` is configured), users
matching that suffix are highlighted in green; everyone else is red.
Usage:
synapse-room-member-flow.py <ROOM_ID> [--server :example.com]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import bold, client_request, gray, green, load_config, quote, red
def _get_event(config: dict, room_id: str, sender: str, event_id: str) -> dict | None:
filt = json.dumps({"senders": [sender]})
endpoint = (
f"/rooms/{quote(room_id)}/context/{quote(event_id)}"
f"?limit=1&filter={quote(filt)}"
)
res = client_request(config, "GET", endpoint)
if isinstance(res, dict):
return res.get("event")
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("room_id")
parser.add_argument("--server", default=None)
args = parser.parse_args()
config = load_config()
suffix = args.server if args.server is not None else config.get("room_filter", "")
state = client_request(config, "GET", f"/rooms/{quote(args.room_id)}/state")
if isinstance(state, dict) and "error" in state:
print(f"Error: {state['error']}", file=sys.stderr)
return 1
if not isinstance(state, list):
# The /state endpoint returns a list; some implementations wrap it.
state = state.get("state", []) if isinstance(state, dict) else []
events: list[tuple[datetime, str, str, str | None]] = []
for s in state:
if s.get("type") != "m.room.member":
continue
ts = datetime.fromtimestamp(
(s.get("origin_server_ts") or 0) / 1000, tz=timezone.utc
)
membership = (s.get("content") or {}).get("membership")
state_key = s.get("state_key")
if not state_key:
continue
if membership == "join":
display = (s.get("content") or {}).get("displayname")
events.append((ts, "join", state_key, display))
else:
replaces = s.get("replaces_state")
if replaces:
old = _get_event(config, args.room_id, state_key, replaces)
if old:
old_ts = datetime.fromtimestamp(
(old.get("origin_server_ts") or 0) / 1000, tz=timezone.utc
)
old_name = (old.get("content") or {}).get("displayname")
events.append((old_ts, "join", state_key, old_name))
events.append((ts, "leave", state_key, old_name))
continue
events.append((ts, membership or "leave", state_key, None))
events.sort(key=lambda e: e[0])
for ts, kind, uid, name in events:
ts_str = gray(ts.isoformat())
is_local = bool(suffix) and uid.endswith(suffix)
color = green if is_local else red
label = uid + (f" ({name})" if name else "")
user = bold(color(label))
if kind == "join":
arrow = bold(green("→"))
else:
arrow = bold(red("←"))
print(f"{ts_str} {arrow} {user}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Search a single room for unencrypted messages by a user.
Calls ``POST /_matrix/client/v3/search``. Only **unencrypted** messages
are searchable — end-to-end encrypted rooms return nothing. The token
holder must be a member of the room.
Usage:
synapse-search.py <ROOM_ID> <USER_ID> <TERM…>
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import client_request, gray, load_config, quote
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("room_id")
parser.add_argument("user_id")
parser.add_argument("terms", nargs="+")
args = parser.parse_args()
config = load_config()
term = " ".join(args.terms)
next_batch: str | None = None
messages: list[dict] = []
while True:
endpoint = "/search"
if next_batch is not None:
endpoint += f"?next_batch={quote(next_batch)}"
body = {
"search_categories": {
"room_events": {
"groupings": {"group_by": [{"key": "room_id"}]},
"filter": {
"limit": 1000,
"senders": [args.user_id],
"rooms": [args.room_id],
},
"keys": ["content.body"],
"order_by": "recent",
"search_term": term,
}
}
}
result = client_request(config, "POST", endpoint, body)
if "error" in result:
print(f"Error: {result['error']}", file=sys.stderr)
return 1
events = (
result.get("search_categories", {})
.get("room_events", {})
.get("results", [])
)
for entry in events:
ev = entry.get("result") or {}
ts = ev.get("origin_server_ts") or 0
messages.append(
{
"ts": datetime.fromtimestamp(ts / 1000, tz=timezone.utc),
"msgtype": (ev.get("content") or {}).get("msgtype"),
"body": (ev.get("content") or {}).get("body", ""),
}
)
next_batch = (
result.get("search_categories", {}).get("room_events", {}).get("next_batch")
)
if not next_batch:
break
messages.sort(key=lambda m: m["ts"])
for m in messages:
ts = gray(f"[{m['ts'].isoformat()}]")
suffix = gray(f"({m['msgtype']})") if m["msgtype"] else ""
print(f"{ts} {m['body']} {suffix}".rstrip())
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""List rooms where the given user is a power-level-100 admin.
Reads the local ``rooms.json`` snapshot (produced by
``synapse-fetch-rooms.py``) and counts how many *other* admins are present
in each room — useful for spotting single-admin / single-point-of-failure
rooms.
Usage:
synapse-user-admin-rooms.py [USER_ID] [--input rooms.json]
Falls back to ``$MATRIX_USER_ID``.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import bold, condense, gray, green, red, yellow
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("user_id", nargs="?", default=None)
parser.add_argument("-i", "--input", default="rooms.json")
args = parser.parse_args()
user_id = args.user_id or os.environ.get("MATRIX_USER_ID")
if not user_id:
print("Error: USER_ID required.", file=sys.stderr)
return 2
with open(args.input) as f:
data = json.load(f)
rooms = condense(data)
matches = []
for r in rooms.values():
if r.is_replaced:
continue
if r.permissions.get(user_id, 0) == 100:
others = [
uid
for uid, lvl in r.permissions.items()
if uid != user_id and lvl == 100
]
matches.append((r, others))
matches.sort(key=lambda pair: pair[0].name.lower())
if not matches:
print(gray(f"{user_id} is not an admin in any room"))
return 0
max_name = max(len(r.name) for r, _ in matches)
for r, others in matches:
# Pad the plain name first; colour codes don't count toward width.
padding = " " * (max_name - len(r.name))
name = bold(yellow(r.name)) + padding
if others:
label = green(f"({len(others)} other admins)")
else:
label = red("(no other admins)")
print(f"{name} {label}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""List every room where the given user is a member.
Reads the local ``rooms.json`` snapshot. For each room, prints version
and the timestamp of the user's join event. Replaced (tombstoned) rooms
are dimmed.
Usage:
synapse-user-rooms.py [USER_ID] [--input rooms.json]
Falls back to ``$MATRIX_USER_ID``.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _lib import bold, condense, gray, yellow
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("user_id", nargs="?", default=None)
parser.add_argument("-i", "--input", default="rooms.json")
args = parser.parse_args()
user_id = args.user_id or os.environ.get("MATRIX_USER_ID")
if not user_id:
print("Error: USER_ID required.", file=sys.stderr)
return 2
with open(args.input) as f:
data = json.load(f)
rooms = condense(data)
matches = []
for r in rooms.values():
joined = r.members.get(user_id)
if joined is None:
continue
matches.append((r, joined))
matches.sort(key=lambda pair: pair[0].name.lower())
if not matches:
print(gray(f"{user_id} is not a member of any indexed room"))
return 0
for r, joined in matches:
color = gray if r.is_replaced else yellow
name = bold(color(r.name))
info = gray(f"(v{r.version}, joined {joined.isoformat()})")
print(f"{name} {info}")
return 0
if __name__ == "__main__":
sys.exit(main())