Files
rrcd/rrcd/cli.py

364 lines
11 KiB
Python

from __future__ import annotations
import argparse
import logging
import os
import sys
from dataclasses import asdict, replace
import RNS
from .config import HubRuntimeConfig
from .paths import (
default_config_path,
default_identity_path,
default_room_registry_path,
ensure_private_dir,
)
from .service import HubService
def _load_toml(path: str) -> dict:
import tomllib
with open(path, "rb") as f:
return tomllib.load(f)
def _apply_config_file(cfg: HubRuntimeConfig, path: str) -> HubRuntimeConfig:
data = _load_toml(path)
hub = data.get("hub") if isinstance(data, dict) else None
if isinstance(hub, dict):
data = {**data, **hub}
allowed = set(asdict(cfg).keys())
# This identifies where to reload/persist from; do not let the file override it.
allowed.discard("config_path")
updates = {k: v for k, v in data.items() if k in allowed}
for list_key in ("trusted_identities", "banned_identities"):
if list_key in updates and isinstance(updates[list_key], list):
updates[list_key] = tuple(str(x) for x in updates[list_key])
if "announce" in data and "announce_on_start" not in updates:
try:
updates["announce_on_start"] = bool(data["announce"])
except Exception:
pass
if "configdir" in updates and updates["configdir"] == "":
updates["configdir"] = None
if "greeting" in updates and updates["greeting"] == "":
updates["greeting"] = None
return replace(cfg, **updates) if updates else cfg
def _write_default_config(config_path: str, identity_path: str) -> None:
cfg_dir = os.path.dirname(config_path)
if cfg_dir:
ensure_private_dir(__import__("pathlib").Path(cfg_dir))
storage_dir = os.path.dirname(identity_path)
if storage_dir:
ensure_private_dir(__import__("pathlib").Path(storage_dir))
room_registry_path = str(default_room_registry_path())
content = f"""# rrcd configuration (TOML)
#
# This file was created on first run.
# Edit it, then start rrcd again.
[hub]
# Optional: Reticulum configuration directory.
# If left unset, Reticulum will choose its default (usually ~/.reticulum).
configdir = ""
# Where rrcd stores its persistent identity (Reticulum Identity file).
identity_path = {identity_path!r}
# Separate room registry file (registered rooms, topics, modes, bans, etc).
# This file is maintained by rrcd. You can edit it manually, but keep it valid TOML.
# A running hub can reload both rrcd.toml and rooms.toml with the /reload command.
room_registry_path = {room_registry_path!r}
# Destination name to host the hub on.
dest_name = "rrc.hub"
# Announcing (Reticulum destination announces)
#
# announce_on_start: send a single announce right after startup.
# announce_period_s: if >0, periodically re-announce.
# To disable announcing entirely, set:
# announce_on_start = false
# announce_period_s = 0.0
announce_on_start = true
announce_period_s = 0.0
# WELCOME message fields.
hub_name = "rrc"
greeting = ""
# Operator / moderation
#
# trusted_identities: list of Reticulum Identity hashes (hex) allowed to run
# operator commands.
# banned_identities: list of Identity hashes (hex) that will be disconnected.
trusted_identities = []
banned_identities = []
# Registered-room pruning.
# Only applies to registered rooms with no connected members.
room_registry_prune_after_s = {30 * 24 * 3600}
room_registry_prune_interval_s = 3600.0
# Keyed-room invites.
# Room operators can use /invite to let a user join a +k room without the key.
# Invites are removed on join or after this timeout.
room_invite_timeout_s = 900.0
# Optional behaviors.
include_joined_member_list = false
# Nickname policy.
# Maximum accepted nickname length (Unicode characters). 0 disables length limiting.
nick_max_chars = 32
# Limits.
max_rooms_per_session = 32
max_room_name_len = 64
rate_limit_msgs_per_minute = 240
# Hub-initiated liveness checks (0 disables).
ping_interval_s = 0.0
ping_timeout_s = 0.0
"""
with open(config_path, "w", encoding="utf-8") as f:
f.write(content)
def _ensure_first_run_files(
config_path: str, identity_path: str, room_registry_path: str
) -> bool:
created_any = False
if not os.path.exists(config_path):
_write_default_config(config_path, identity_path)
created_any = True
if not os.path.exists(identity_path):
storage_dir = os.path.dirname(identity_path)
if storage_dir:
ensure_private_dir(__import__("pathlib").Path(storage_dir))
ident = RNS.Identity()
ident.to_file(identity_path)
try:
os.chmod(identity_path, 0o600)
except Exception:
pass
created_any = True
if room_registry_path and not os.path.exists(room_registry_path):
storage_dir = os.path.dirname(room_registry_path)
if storage_dir:
ensure_private_dir(__import__("pathlib").Path(storage_dir))
content = """# rrcd room registry (TOML)
#
# This file stores registered rooms and their moderation state.
# It is maintained by rrcd and may be updated while rrcd is running.
#
# Schema
# ------
#
# Each registered room is a table under [rooms]. Room names are TOML keys.
# If your room name contains spaces or punctuation, quote it:
#
# [rooms."my room"]
#
# Supported keys per room:
#
# - founder: string, hex Reticulum Identity hash
# - topic: string (optional)
# - moderated: bool (defaults false)
# - operators: list of string identity hashes (hex)
# - voiced: list of string identity hashes (hex)
# - bans: list of string identity hashes (hex)
# - invited: table mapping identity hash (hex) -> expiry unix timestamp seconds
# - last_used_ts: float unix timestamp seconds (used for pruning; optional)
#
# Example
# -------
#
# [rooms."lobby"]
# founder = "0123abcd..."
# topic = "Welcome"
# moderated = false
# operators = ["0123abcd..."]
# voiced = []
# bans = []
# invited = { "89abcdef..." = 1730003600.0 }
# last_used_ts = 1730000000.0
[rooms]
"""
with open(room_registry_path, "w", encoding="utf-8") as f:
f.write(content)
try:
os.chmod(room_registry_path, 0o600)
except Exception:
pass
created_any = True
return created_any
def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="rrcd", description="Run an RRC hub daemon")
p.add_argument(
"--config",
default=str(default_config_path()),
help="Path to a TOML config file (created on first run)",
)
p.add_argument("--configdir", default=None, help="Reticulum config directory")
p.add_argument(
"--identity",
default=str(default_identity_path()),
help="Path to hub identity file (created on first run)",
)
p.add_argument(
"--room-registry",
default=str(default_room_registry_path()),
help="Path to separate room registry TOML (created on first run)",
)
p.add_argument(
"--dest-name", default=None, help="Destination app name (default: rrc.hub)"
)
p.add_argument(
"--no-announce",
action="store_true",
help="Disable announce on start (does not affect periodic announce)",
)
p.add_argument(
"--announce-period",
type=float,
default=None,
help="Periodic announce interval seconds (0 disables)",
)
p.add_argument("--hub-name", default=None, help="Hub name in WELCOME")
p.add_argument("--greeting", default=None, help="Greeting in WELCOME")
p.add_argument(
"--include-joined-member-list",
action="store_true",
help="Include member list in JOINED (best-effort)",
)
p.add_argument("--max-rooms", type=int, default=None, help="Max rooms per session")
p.add_argument(
"--max-room-name-len", type=int, default=None, help="Max room name length"
)
p.add_argument(
"--rate-limit-msgs-per-minute",
type=int,
default=None,
help="Per-link message rate limit",
)
p.add_argument(
"--ping-interval",
type=float,
default=None,
help="Hub-initiated PING interval seconds (0 disables)",
)
p.add_argument(
"--ping-timeout",
type=float,
default=None,
help="Close link if PONG not received within this many seconds (0 disables)",
)
p.add_argument(
"--log-level",
default="INFO",
help="Logging level (DEBUG, INFO, WARNING, ERROR)",
)
return p
def main(argv: list[str] | None = None) -> None:
args = _build_arg_parser().parse_args(sys.argv[1:] if argv is None else argv)
config_path = str(args.config)
identity_path = str(args.identity)
room_registry_path = str(args.room_registry)
if _ensure_first_run_files(config_path, identity_path, room_registry_path):
print(
"Created default rrcd files. Edit the configuration before starting:\n"
f"- Config: {config_path}\n"
f"- Identity: {identity_path}\n"
f"- Rooms: {room_registry_path}\n"
"\nThen re-run rrcd.",
file=sys.stderr,
)
raise SystemExit(0)
cfg = HubRuntimeConfig(configdir=args.configdir, identity_path=identity_path)
cfg = replace(cfg, config_path=config_path)
cfg = replace(cfg, room_registry_path=room_registry_path)
if config_path:
cfg = _apply_config_file(cfg, config_path)
if args.dest_name is not None:
cfg = replace(cfg, dest_name=args.dest_name)
if args.no_announce:
cfg = replace(cfg, announce_on_start=False)
if args.announce_period is not None:
cfg = replace(cfg, announce_period_s=float(args.announce_period))
if args.hub_name is not None:
cfg = replace(cfg, hub_name=args.hub_name)
if args.greeting is not None:
cfg = replace(cfg, greeting=args.greeting)
if args.include_joined_member_list:
cfg = replace(cfg, include_joined_member_list=True)
if args.max_rooms is not None:
cfg = replace(cfg, max_rooms_per_session=int(args.max_rooms))
if args.max_room_name_len is not None:
cfg = replace(cfg, max_room_name_len=int(args.max_room_name_len))
if args.rate_limit_msgs_per_minute is not None:
cfg = replace(
cfg, rate_limit_msgs_per_minute=int(args.rate_limit_msgs_per_minute)
)
if args.ping_interval is not None:
cfg = replace(cfg, ping_interval_s=float(args.ping_interval))
if args.ping_timeout is not None:
cfg = replace(cfg, ping_timeout_s=float(args.ping_timeout))
logging.basicConfig(
level=getattr(logging, str(args.log_level).upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
svc = HubService(cfg)
svc.start()
svc.run_forever()
if __name__ == "__main__":
main()