#!/usr/bin/env python3 """ oko user management script. Adds/removes end users and provisions private Matrix rooms on ayrc.online so each user has a space to view their surveillance clips. Usage: python3 manage_users.py add --user alice --matrix-id @alice:ayrc.online python3 manage_users.py remove --user alice python3 manage_users.py list python3 manage_users.py info --user alice Reads the bot token from matrix.token (same file the coordinator uses). Stores user data in users.json at the project root. """ import argparse import json import os import sys import time import urllib.error import urllib.request SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) USERS_FILE = os.path.join(SCRIPT_DIR, "users.json") TOKEN_FILE = os.path.join(SCRIPT_DIR, "matrix.token") MATRIX_HOMESERVER = "https://ayrc.online" MATRIX_BOT_USER = "@okobot:ayrc.online" # ── Matrix helpers (raw Client-Server API, no dependencies) ────────────── def matrix_request(method, path, token, body=None): """Send a request to the Matrix Client-Server API and return the parsed response. Raises on HTTP errors.""" url = f"{MATRIX_HOMESERVER}{path}" data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", f"Bearer {token}") req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read()) except urllib.error.HTTPError as exc: err_body = exc.read().decode(errors="replace") raise SystemExit(f"Matrix API error {exc.code}: {err_body}") from exc def matrix_create_room(token, name, topic, invite_user): """Create an invite-only private room, set its name and topic, then invite *invite_user*. Returns the room ID.""" resp = matrix_request("POST", "/_matrix/client/v3/createRoom", token, { "name": name, "topic": topic, "room_version": "10", "is_direct": False, "visibility": "private", "invite": [invite_user], "preset": "private_chat", "initial_state": [ { "type": "m.room.join_rules", "content": {"join_rule": "invite"}, }, { "type": "m.room.history_visibility", "content": {"history_visibility": "shared"}, }, ], }) return resp["room_id"] def matrix_send_notice(token, room_id, text): """Send a human-readable notice into a room.""" matrix_request("POST", f"/_matrix/client/v3/rooms/{room_id}/send/m.room.message", token, { "msgtype": "m.text", "body": text, }) # ── User store ─────────────────────────────────────────────────────────── def load_users(): if not os.path.exists(USERS_FILE): return {} with open(USERS_FILE) as f: return json.load(f) def save_users(users): with open(USERS_FILE, "w") as f: json.dump(users, f, indent=2, sort_keys=True) f.write("\n") # ── Commands ───────────────────────────────────────────────────────────── def cmd_add(args): users = load_users() name = args.user.lower() if name in users and users[name].get("active", True): print(f"User '{name}' already exists and is active.") sys.exit(1) matrix_id = args.matrix_id if not matrix_id.startswith("@"): print("Matrix user ID must start with '@' (e.g. @alice:ayrc.online)") sys.exit(1) bot_token = open(TOKEN_FILE).read().strip() if not bot_token: print(f"Cannot read bot token from {TOKEN_FILE}") sys.exit(1) display_name = args.display_name or name room_name = f"oko - {display_name}" room_topic = f"Private surveillance clips for {display_name}" print(f"Creating private Matrix room for {display_name}...") room_id = matrix_create_room(bot_token, room_name, room_topic, matrix_id) print(f" Room created: {room_id}") greeting = ( f"Welcome, {display_name}! This is your private oko surveillance room.\n" f"Clips from your cameras will appear here." ) matrix_send_notice(bot_token, room_id, greeting) print(f" Greeting posted to room") users[name] = { "display_name": display_name, "matrix_id": matrix_id, "room_id": room_id, "created_at": int(time.time()), "active": True, } save_users(users) print(f"User '{name}' saved to {USERS_FILE}") print(f"\nDone. Invite the user to log in to Matrix and accept the room invite.") def cmd_remove(args): users = load_users() name = args.user.lower() if name not in users: print(f"User '{name}' not found.") sys.exit(1) if not users[name].get("active", True): print(f"User '{name}' is already inactive.") sys.exit(0) users[name]["active"] = False users[name]["removed_at"] = int(time.time()) save_users(users) print(f"User '{name}' marked as inactive.") print(f" Matrix room {users[name]['room_id']} was left intact.") def cmd_list(args): users = load_users() if not users: print("No users registered.") return active = [(n, u) for n, u in users.items() if u.get("active", True)] inactive = [(n, u) for n, u in users.items() if not u.get("active", True)] if active: print(f"Active users ({len(active)}):") for name, u in active: print(f" {name:20s} {u['matrix_id']:30s} room {u['room_id']}") if inactive: print(f"Inactive users ({len(inactive)}):") for name, u in inactive: print(f" {name:20s} {u['matrix_id']:30s} (removed)") if not active and not inactive: print("No users registered.") def cmd_info(args): users = load_users() name = args.user.lower() if name not in users: print(f"User '{name}' not found.") sys.exit(1) u = users[name] status = "active" if u.get("active", True) else "inactive" created = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(u.get("created_at", 0))) print(f"User: {name}") print(f"Status: {status}") print(f"Display: {u.get('display_name', '')}") print(f"Matrix ID: {u.get('matrix_id', '')}") print(f"Room ID: {u.get('room_id', '')}") print(f"Created: {created}") # ── CLI ────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="oko user management – add, remove, and list surveillance users", ) sub = parser.add_subparsers(dest="command", required=True) p_add = sub.add_parser("add", help="add a new user and create their Matrix room") p_add.add_argument("--user", required=True, help="username (lowercase, no spaces)") p_add.add_argument("--matrix-id", required=True, help="Matrix user ID, e.g. @alice:ayrc.online") p_add.add_argument("--display-name", default=None, help="human-readable display name (defaults to username)") p_rm = sub.add_parser("remove", help="deactivate a user") p_rm.add_argument("--user", required=True, help="username to remove") p_ls = sub.add_parser("list", help="list all users") p_info = sub.add_parser("info", help="show details for a user") p_info.add_argument("--user", required=True, help="username to inspect") args = parser.parse_args() if args.command == "add": cmd_add(args) elif args.command == "remove": cmd_remove(args) elif args.command == "list": cmd_list(args) elif args.command == "info": cmd_info(args) if __name__ == "__main__": main()