241 lines
8.0 KiB
Python
241 lines
8.0 KiB
Python
#!/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()
|