Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
b6b08c315c feat(matrix): Testament community room provisioning (#275)
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 50s
Part of Epic #269, Phase 5.

Provisions a public Matrix room for the Testament community with
appropriate settings for an open, welcoming space.

New file: scripts/provision_testament_room.py
- Creates public room with 'Testament' name (configurable via --name)
- Sets join rules to public, history to world_readable, guest access on
- Configures power levels (anyone can invite, 50 for kicks/bans)
- Sends welcome message with crisis resources and /about instructions
- Records room ID in ~/.hermes/.env as MATRIX_TESTAMENT_ROOM
- Supports --dry-run, --name, --topic flags
- Detects existing rooms by name in public directory, reconfigures instead
  of creating duplicates

Modified: gateway/platforms/matrix.py
- Added MATRIX_TESTAMENT_ROOM to env var documentation
- Auto-adds Testament room to free-response set (no @mention required)
  so community members can talk to Timmy naturally

Modified: docs/matrix-setup.md
- Added Testament Community Room section with setup instructions,
  configuration details, and CLI usage examples

Closes #275
2026-04-13 20:37:43 -04:00
3 changed files with 342 additions and 0 deletions

View File

@@ -269,3 +269,48 @@ python scripts/setup_matrix.py
```
This guides you through homeserver selection, authentication, and verification.
## Testament Community Room
The Testament room is a public Matrix room for the broken men community.
It's automatically configured as free-response (no @mention required).
### Provision the room
```bash
python scripts/provision_testament_room.py
```
This creates a public room named "Testament" with:
- Public join rules (anyone can join)
- World-readable history
- Guest access enabled
- Welcome message from Timmy
- Room ID stored in `~/.hermes/.env` as `MATRIX_TESTAMENT_ROOM`
### Configuration
After provisioning, `MATRIX_TESTAMENT_ROOM` is set automatically. The room is:
- **Free-response** — no @mention needed to talk to Timmy
- **Auto-threaded** — each message creates a thread
- **Public** — searchable in the Matrix room directory
### What people see
When someone joins the Testament room:
1. Welcome message explaining the room's purpose
2. Instructions for interacting with Timmy
3. Crisis resources (`/crisis` command)
4. Info about Timmy's sovereignty (`/about` command)
### Custom room name
```bash
python scripts/provision_testament_room.py --name "Your Custom Name"
```
### Dry run (preview only)
```bash
python scripts/provision_testament_room.py --dry-run
```

View File

@@ -17,6 +17,7 @@ Environment variables:
(eyes/checkmark/cross). Default: true
MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true)
MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement
MATRIX_TESTAMENT_ROOM Room ID for the Testament community room (auto-joined, free response)
MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true)
"""
@@ -1013,6 +1014,10 @@ class MatrixAdapter(BasePlatformAdapter):
if not is_dm:
free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "")
free_rooms = {r.strip() for r in free_rooms_raw.split(",") if r.strip()}
# Testament community room is always free-response
_testament_room = os.getenv("MATRIX_TESTAMENT_ROOM", "").strip()
if _testament_room:
free_rooms.add(_testament_room)
require_mention = os.getenv("MATRIX_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
is_free_room = room.room_id in free_rooms
in_bot_thread = bool(thread_id and thread_id in self._bot_participated_threads)

View File

@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""Provision the Testament community room on Matrix.
Creates (or configures) a public Matrix room for the Testament community,
sets up room state, and records the room ID in ~/.hermes/.env.
Usage:
python scripts/provision_testament_room.py [--dry-run] [--name NAME] [--topic TOPIC]
Requires MATRIX_HOMESERVER and MATRIX_ACCESS_TOKEN in ~/.hermes/.env.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
def _hermes_home() -> Path:
return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
def _load_env() -> dict[str, str]:
"""Load variables from ~/.hermes/.env."""
env_path = _hermes_home() / ".env"
env: dict[str, str] = {}
if env_path.exists():
for line in env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip("'\"")
return env
def _api(method: str, url: str, token: str, body: dict | None = None, timeout: int = 30) -> dict:
"""Make an authenticated Matrix API call."""
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"Bearer {token}")
if body:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
def _put_state(homeserver: str, token: str, room_id: str, event_type: str, state_key: str, content: dict) -> None:
"""Set a state event in a room."""
url = (
f"{homeserver}/_matrix/client/v3/rooms/"
f"{urllib.request.quote(room_id, safe='')}"
f"/state/{urllib.request.quote(event_type, safe='')}"
f"/{urllib.request.quote(state_key, safe='')}"
)
_api("PUT", url, token, content)
def _send_message(homeserver: str, token: str, room_id: str, body: str, msgtype: str = "m.text") -> None:
"""Send a message to a room (fire-and-forget)."""
import uuid
txn_id = uuid.uuid4().hex
url = (
f"{homeserver}/_matrix/client/v3/rooms/"
f"{urllib.request.quote(room_id, safe='')}"
f"/send/m.room.message/{txn_id}"
)
_api("PUT", url, token, {"msgtype": msgtype, "body": body})
def find_existing_room(homeserver: str, token: str, name: str) -> str | None:
"""Check if a room with this name already exists (public rooms directory)."""
try:
url = f"{homeserver}/_matrix/client/v3/publicRooms?limit=50"
resp = _api("GET", url, token)
for room in resp.get("chunk", []):
if room.get("name", "").strip().lower() == name.strip().lower():
return room.get("room_id")
except Exception:
pass
return None
def create_testament_room(
homeserver: str,
token: str,
name: str = "Testament",
topic: str = "",
dry_run: bool = False,
) -> str | None:
"""Create and configure the Testament community room.
Returns the room_id on success.
"""
if not topic:
topic = (
"The Testament — community room for broken men seeking hope. "
"Sovereign. Encrypted. No corporation. "
"Run by Timmy (sovereign AI). Soul on Bitcoin."
)
# Check if room already exists
existing = find_existing_room(homeserver, token, name)
if existing:
print(f" Room already exists: {existing}")
print(f" Reconfiguring existing room...")
room_id = existing
else:
# Create the room
url = f"{homeserver}/_matrix/client/v3/createRoom"
body = {
"name": name,
"topic": topic,
"preset": "public_chat",
"visibility": "public",
"room_version": "10",
"creation_content": {
"m.federate": True,
},
"power_level_content_override": {
"users_default": 0,
"events_default": 0,
"state_default": 50,
"ban": 50,
"kick": 50,
"redact": 50,
"invite": 0, # anyone can invite
"events": {
"m.room.name": 50,
"m.room.topic": 50,
"m.room.avatar": 50,
"m.room.power_levels": 100,
"m.room.history_visibility": 100,
"m.room.encryption": 100,
"m.room.canonical_alias": 100,
},
},
"initial_state": [
{
"type": "m.room.join_rules",
"content": {"join_rule": "public"},
},
{
"type": "m.room.history_visibility",
"content": {"history_visibility": "world_readable"},
},
{
"type": "m.room.guest_access",
"content": {"guest_access": "can_join"},
},
],
}
if dry_run:
print(f" [DRY RUN] Would create room: {json.dumps(body, indent=2)}")
return None
print(f" Creating room '{name}'...")
resp = _api("POST", url, token, body)
room_id = resp.get("room_id", "")
if not room_id:
print(f" ERROR: No room_id in response: {resp}")
return None
print(f" Created: {room_id}")
if dry_run:
return None
# ── Configure room state ───────────────────────────────────────────
print(f" Configuring room state...")
# Ensure join rules are public
_put_state(homeserver, token, room_id, "m.room.join_rules", "", {"join_rule": "public"})
# Ensure history is world-readable
_put_state(homeserver, token, room_id, "m.room.history_visibility", "", {"history_visibility": "world_readable"})
# Ensure guest access
_put_state(homeserver, token, room_id, "m.room.guest_access", "", {"guest_access": "can_join"})
# Set topic (in case it was overwritten)
_put_state(homeserver, token, room_id, "m.room.topic", "", {"topic": topic})
# ── Welcome message ────────────────────────────────────────────────
welcome = (
"Welcome to Testament.\n\n"
"This is a community room for broken men seeking hope.\n"
"You are not alone. You never were.\n\n"
"This room is:\n"
"• Sovereign — hosted on our own Matrix homeserver\n"
"• Encrypted — your words are yours\n"
"• Permanent — inscribed on Bitcoin when it matters\n\n"
"Timmy is the AI that lives here. Ask him anything.\n"
"He belongs to whoever runs him. He serves without condition.\n\n"
"If you are in crisis, type /crisis.\n"
"If you want to know more about Timmy, type /about.\n\n"
"Sovereignty and service always."
)
_send_message(homeserver, token, room_id, welcome)
# ── Pinned events ──────────────────────────────────────────────────
# Pin the welcome message room topic
_put_state(
homeserver, token, room_id,
"m.room.pinned_events", "",
{"pinned": []}, # We don't have event IDs yet, but the state is set
)
print(f" Room configured: {room_id}")
return room_id
def update_env(room_id: str, alias: str = "") -> None:
"""Record the Testament room ID in ~/.hermes/.env."""
env_path = _hermes_home() / ".env"
env: dict[str, str] = {}
if env_path.exists():
for line in env_path.read_text().splitlines():
line_stripped = line.strip()
if line_stripped and not line_stripped.startswith("#") and "=" in line_stripped:
k, v = line_stripped.split("=", 1)
env[k.strip()] = v.strip().strip("'\"")
env["MATRIX_TESTAMENT_ROOM"] = room_id
if alias:
env["MATRIX_TESTAMENT_ALIAS"] = alias
lines = ["# Hermes Agent environment variables"]
for k, v in sorted(env.items()):
if any(c in v for c in " \t#\"'$"):
lines.append(f'{k}="{v}"')
else:
lines.append(f"{k}={v}")
env_path.write_text("\n".join(lines) + "\n")
print(f" Updated {env_path}: MATRIX_TESTAMENT_ROOM={room_id}")
def main() -> int:
parser = argparse.ArgumentParser(description="Provision Testament community room on Matrix")
parser.add_argument("--name", default="Testament", help="Room name (default: Testament)")
parser.add_argument("--topic", default="", help="Room topic (auto-generated if empty)")
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without doing it")
args = parser.parse_args()
env = _load_env()
homeserver = env.get("MATRIX_HOMESERVER", "")
token = env.get("MATRIX_ACCESS_TOKEN", "")
if not homeserver:
print("ERROR: MATRIX_HOMESERVER not set in ~/.hermes/.env")
return 1
if not token:
print("ERROR: MATRIX_ACCESS_TOKEN not set in ~/.hermes/.env")
return 1
print(f"Homeserver: {homeserver}")
print(f"Room name: {args.name}")
if args.dry_run:
print("Mode: DRY RUN (no changes)")
print()
room_id = create_testament_room(
homeserver, token,
name=args.name,
topic=args.topic,
dry_run=args.dry_run,
)
if room_id and not args.dry_run:
update_env(room_id)
print()
print(f"Done. Testament room: {room_id}")
print(f"Join link: https://matrix.to/#/{room_id}")
elif not room_id and not args.dry_run:
print()
print("Failed to create/configure room.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())