Refactor LoginHelper and TextHelper to implement per-identity ACLs add CLI command handling

This commit is contained in:
Lloyd
2025-12-17 11:08:14 +00:00
parent 4b97c7b439
commit 1bb2d39940
4 changed files with 724 additions and 47 deletions
+43 -17
View File
@@ -13,42 +13,56 @@ logger = logging.getLogger("LoginHelper")
class LoginHelper:
def __init__(self, identity_manager, packet_injector=None, acl=None, log_fn=None):
def __init__(self, identity_manager, packet_injector=None, log_fn=None):
self.identity_manager = identity_manager
self.packet_injector = packet_injector
self.log_fn = log_fn or logger.info
self.acl = acl
self.handlers = {}
self.acls = {} # Per-identity ACLs keyed by hash_byte
def register_identity(self, name: str, identity, identity_type: str = "room_server", config: dict = None):
if not self.acl:
logger.warning(f"Cannot register identity '{name}': no ACL configured")
return
config = config or {}
# Validate room servers have their own passwords
hash_byte = identity.get_public_key()[0]
# Create ACL for this identity
from repeater.handler_helpers.acl import ACL
# Get security config for this identity
if identity_type == "room_server":
security = config.get("security", {})
# Validate room servers have their own passwords
if not security.get("admin_password") and not security.get("guest_password"):
logger.error(
f"Room server '{name}' MUST have security.admin_password or "
f"security.guest_password configured. Skipping registration."
)
return
else:
# Repeater uses security from its config
security = config.get("security", {})
hash_byte = identity.get_public_key()[0]
# Create ACL for this identity
identity_acl = ACL(
max_clients=security.get("max_clients", 50),
admin_password=security.get("admin_password", "admin123"),
guest_password=security.get("guest_password", "guest123"),
allow_read_only=security.get("allow_read_only", True),
)
# Create auth callback that includes identity context
self.acls[hash_byte] = identity_acl
logger.info(f"Created ACL for {identity_type} '{name}': hash=0x{hash_byte:02X}")
# Create auth callback that uses this identity's ACL
def auth_callback_with_context(client_identity, shared_secret, password, timestamp):
return self.acl.authenticate_client(
return identity_acl.authenticate_client(
client_identity=client_identity,
shared_secret=shared_secret,
password=password,
timestamp=timestamp,
target_identity_hash=hash_byte, # Which identity is being logged into
target_identity_hash=hash_byte,
target_identity_name=name,
target_identity_config=config
)
@@ -105,10 +119,22 @@ class LoginHelper:
except Exception as e:
logger.error(f"Error sending login response: {e}")
def get_acl(self):
return self.acl
def get_acl_dict(self):
"""Return dictionary of ACLs keyed by identity hash."""
return self.acls
def get_acl_for_identity(self, hash_byte: int):
"""Get ACL for a specific identity."""
return self.acls.get(hash_byte)
def list_authenticated_clients(self):
if self.acl:
return self.acl.get_all_clients()
return []
def list_authenticated_clients(self, hash_byte: int = None):
"""List authenticated clients for a specific identity or all identities."""
if hash_byte is not None:
acl = self.acls.get(hash_byte)
return acl.get_all_clients() if acl else []
# Return clients from all ACLs
all_clients = []
for acl in self.acls.values():
all_clients.extend(acl.get_all_clients())
return all_clients
+562
View File
@@ -0,0 +1,562 @@
"""
Repeater CLI Handler
Handles administrative commands sent to the repeater via TXT_MSG packets.
Only users with admin permissions (via ACL) can execute these commands.
"""
import logging
from typing import Optional, Dict, Any, Callable
import yaml
from pathlib import Path
import time
logger = logging.getLogger(__name__)
class RepeaterCLI:
"""
CLI command handler for repeater administration.
Commands follow the format: XX|command params
where XX is an optional sequence number that gets echoed in the reply.
"""
def __init__(self, config_path: str, config: Dict[str, Any], save_config_callback: Callable):
"""
Initialize the CLI handler.
Args:
config_path: Path to the config.yaml file
config: Current configuration dictionary
save_config_callback: Callback to save config changes
"""
self.config_path = Path(config_path)
self.config = config
self.save_config = save_config_callback
# Get repeater config shortcut
self.repeater_config = config.get('repeater', {})
def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str:
"""
Handle an incoming command from a client.
Args:
sender_pubkey: Public key of sender
command: Command string (may include XX| prefix)
is_admin: Whether sender has admin permissions
Returns:
Reply string to send back to sender
"""
# Check admin permission first
if not is_admin:
return "Error: Admin permission required"
# Extract optional sequence prefix (XX|)
prefix = ""
if len(command) > 4 and command[2] == '|':
prefix = command[:3]
command = command[3:]
# Strip leading/trailing whitespace
command = command.strip()
# Route to appropriate handler
reply = self._route_command(command)
# Add prefix back to reply if present
if prefix:
return prefix + reply
return reply
def _route_command(self, command: str) -> str:
"""Route command to appropriate handler method."""
# System commands
if command == "reboot":
return self._cmd_reboot()
elif command == "advert":
return self._cmd_advert()
elif command.startswith("clock"):
return self._cmd_clock(command)
elif command.startswith("time "):
return self._cmd_time(command)
elif command == "start ota":
return "Error: OTA not supported in Python repeater"
elif command.startswith("password "):
return self._cmd_password(command)
elif command == "clear stats":
return self._cmd_clear_stats()
elif command == "ver":
return self._cmd_version()
# Get commands
elif command.startswith("get "):
return self._cmd_get(command[4:])
# Set commands
elif command.startswith("set "):
return self._cmd_set(command[4:])
# ACL commands
elif command.startswith("setperm "):
return self._cmd_setperm(command)
elif command == "get acl":
return "Error: Use 'get acl' via serial console only"
# Region commands
elif command.startswith("region"):
return self._cmd_region(command)
# Neighbor commands
elif command == "neighbors":
return self._cmd_neighbors()
elif command.startswith("neighbor.remove "):
return self._cmd_neighbor_remove(command)
# Temporary radio params
elif command.startswith("tempradio "):
return self._cmd_tempradio(command)
# Sensor commands
elif command.startswith("sensor "):
return "Error: Sensor commands not implemented in Python repeater"
# GPS commands
elif command.startswith("gps"):
return "Error: GPS commands not implemented in Python repeater"
# Logging commands
elif command.startswith("log "):
return self._cmd_log(command)
# Statistics commands
elif command.startswith("stats-"):
return "Error: Stats commands not fully implemented yet"
else:
return "Unknown command"
# ==================== System Commands ====================
def _cmd_reboot(self) -> str:
"""Reboot the repeater process."""
logger.warning("Reboot command received - not implemented (use systemctl restart)")
return "Error: Use systemctl restart pymc-repeater"
def _cmd_advert(self) -> str:
"""Send self advertisement."""
logger.info("Advert command received")
# TODO: Trigger advertisement through packet handler
return "Error: Not yet implemented"
def _cmd_clock(self, command: str) -> str:
"""Handle clock commands."""
if command == "clock":
# Display current time
import datetime
dt = datetime.datetime.utcnow()
return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC"
elif command == "clock sync":
# Clock sync happens automatically via sender_timestamp in protocol
return "OK - clock sync not needed (system time used)"
else:
return "Unknown clock command"
def _cmd_time(self, command: str) -> str:
"""Set time - not supported in Python (use system time)."""
return "Error: Time setting not supported (system time is used)"
def _cmd_password(self, command: str) -> str:
"""Change admin password."""
new_password = command[9:].strip()
if not new_password:
return "Error: Password cannot be empty"
# Update security config
if 'security' not in self.config:
self.config['security'] = {}
self.config['security']['password'] = new_password
# Save config
try:
self.save_config()
return f"password now: {new_password}"
except Exception as e:
logger.error(f"Failed to save password: {e}")
return "Error: Failed to save password"
def _cmd_clear_stats(self) -> str:
"""Clear statistics."""
# TODO: Implement stats clearing
return "Error: Not yet implemented"
def _cmd_version(self) -> str:
"""Get version information."""
version = self.config.get('version', '1.0.0')
return f"pyMC_Repeater v{version}"
# ==================== Get Commands ====================
def _cmd_get(self, param: str) -> str:
"""Handle get commands."""
param = param.strip()
if param == "af":
af = self.repeater_config.get('airtime_factor', 1.0)
return f"> {af}"
elif param == "name":
name = self.repeater_config.get('name', 'Unknown')
return f"> {name}"
elif param == "repeat":
disabled = self.repeater_config.get('disable_forward', False)
return f"> {'off' if disabled else 'on'}"
elif param == "lat":
lat = self.repeater_config.get('latitude', 0.0)
return f"> {lat}"
elif param == "lon":
lon = self.repeater_config.get('longitude', 0.0)
return f"> {lon}"
elif param == "radio":
radio = self.config.get('radio', {})
freq = radio.get('frequency', 915.0)
bw = radio.get('bandwidth', 125.0)
sf = radio.get('spreading_factor', 7)
cr = radio.get('coding_rate', 5)
return f"> {freq},{bw},{sf},{cr}"
elif param == "freq":
freq = self.config.get('radio', {}).get('frequency', 915.0)
return f"> {freq}"
elif param == "tx":
power = self.config.get('radio', {}).get('tx_power', 20)
return f"> {power}"
elif param == "public.key":
# TODO: Get from identity
return "Error: Not yet implemented"
elif param == "role":
return "> repeater"
elif param == "guest.password":
guest_pw = self.config.get('security', {}).get('guest_password', '')
return f"> {guest_pw}"
elif param == "allow.read.only":
allow = self.config.get('security', {}).get('allow_read_only', False)
return f"> {'on' if allow else 'off'}"
elif param == "advert.interval":
interval = self.repeater_config.get('advert_interval_minutes', 120)
return f"> {interval}"
elif param == "flood.advert.interval":
interval = self.repeater_config.get('flood_advert_interval_hours', 24)
return f"> {interval}"
elif param == "flood.max":
max_flood = self.repeater_config.get('max_flood_hops', 3)
return f"> {max_flood}"
elif param == "rxdelay":
delay = self.repeater_config.get('rx_delay_base', 0.0)
return f"> {delay}"
elif param == "txdelay":
delay = self.repeater_config.get('tx_delay_factor', 1.0)
return f"> {delay}"
elif param == "direct.txdelay":
delay = self.repeater_config.get('direct_tx_delay_factor', 0.5)
return f"> {delay}"
elif param == "multi.acks":
acks = self.repeater_config.get('multi_acks', 0)
return f"> {acks}"
elif param == "int.thresh":
thresh = self.repeater_config.get('interference_threshold', -120)
return f"> {thresh}"
elif param == "agc.reset.interval":
interval = self.repeater_config.get('agc_reset_interval', 0)
return f"> {interval}"
else:
return f"??: {param}"
# ==================== Set Commands ====================
def _cmd_set(self, param: str) -> str:
"""Handle set commands."""
parts = param.split(None, 1)
if len(parts) < 2:
return "Error: Missing value"
key, value = parts[0], parts[1]
try:
if key == "af":
self.repeater_config['airtime_factor'] = float(value)
self.save_config()
return "OK"
elif key == "name":
self.repeater_config['name'] = value
self.save_config()
return "OK"
elif key == "repeat":
disabled = value.lower() == "off"
self.repeater_config['disable_forward'] = disabled
self.save_config()
return f"OK - repeat is now {'OFF' if disabled else 'ON'}"
elif key == "lat":
self.repeater_config['latitude'] = float(value)
self.save_config()
return "OK"
elif key == "lon":
self.repeater_config['longitude'] = float(value)
self.save_config()
return "OK"
elif key == "radio":
# Format: freq bw sf cr
radio_parts = value.split()
if len(radio_parts) != 4:
return "Error: Expected freq bw sf cr"
if 'radio' not in self.config:
self.config['radio'] = {}
self.config['radio']['frequency'] = float(radio_parts[0])
self.config['radio']['bandwidth'] = float(radio_parts[1])
self.config['radio']['spreading_factor'] = int(radio_parts[2])
self.config['radio']['coding_rate'] = int(radio_parts[3])
self.save_config()
return "OK - restart repeater to apply"
elif key == "freq":
if 'radio' not in self.config:
self.config['radio'] = {}
self.config['radio']['frequency'] = float(value)
self.save_config()
return "OK - restart repeater to apply"
elif key == "tx":
if 'radio' not in self.config:
self.config['radio'] = {}
self.config['radio']['tx_power'] = int(value)
self.save_config()
return "OK"
elif key == "guest.password":
if 'security' not in self.config:
self.config['security'] = {}
self.config['security']['guest_password'] = value
self.save_config()
return "OK"
elif key == "allow.read.only":
if 'security' not in self.config:
self.config['security'] = {}
self.config['security']['allow_read_only'] = value.lower() == "on"
self.save_config()
return "OK"
elif key == "advert.interval":
mins = int(value)
if mins > 0 and (mins < 60 or mins > 240):
return "Error: interval range is 60-240 minutes"
self.repeater_config['advert_interval_minutes'] = mins
self.save_config()
return "OK"
elif key == "flood.advert.interval":
hours = int(value)
if (hours > 0 and hours < 3) or hours > 48:
return "Error: interval range is 3-48 hours"
self.repeater_config['flood_advert_interval_hours'] = hours
self.save_config()
return "OK"
elif key == "flood.max":
max_val = int(value)
if max_val > 64:
return "Error: max 64"
self.repeater_config['max_flood_hops'] = max_val
self.save_config()
return "OK"
elif key == "rxdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config['rx_delay_base'] = delay
self.save_config()
return "OK"
elif key == "txdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config['tx_delay_factor'] = delay
self.save_config()
return "OK"
elif key == "direct.txdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config['direct_tx_delay_factor'] = delay
self.save_config()
return "OK"
elif key == "multi.acks":
self.repeater_config['multi_acks'] = int(value)
self.save_config()
return "OK"
elif key == "int.thresh":
self.repeater_config['interference_threshold'] = int(value)
self.save_config()
return "OK"
elif key == "agc.reset.interval":
interval = int(value)
# Round to nearest multiple of 4
rounded = (interval // 4) * 4
self.repeater_config['agc_reset_interval'] = rounded
self.save_config()
return f"OK - interval rounded to {rounded}"
else:
return f"unknown config: {key}"
except ValueError as e:
return f"Error: invalid value - {e}"
except Exception as e:
logger.error(f"Set command error: {e}")
return f"Error: {e}"
# ==================== ACL Commands ====================
def _cmd_setperm(self, command: str) -> str:
"""Set permissions for a public key."""
# Format: setperm {pubkey-hex} {permissions-int}
parts = command[8:].split()
if len(parts) < 2:
return "Err - bad params"
pubkey_hex = parts[0]
try:
permissions = int(parts[1])
except ValueError:
return "Err - invalid permissions"
# TODO: Apply permissions via ACL
logger.info(f"setperm command: {pubkey_hex} -> {permissions}")
return "Error: Not yet implemented - use config file"
# ==================== Region Commands ====================
def _cmd_region(self, command: str) -> str:
"""Handle region commands."""
parts = command.split()
if len(parts) == 1:
return "Error: Region commands not implemented in Python repeater"
subcommand = parts[1]
if subcommand == "load":
return "Error: Region commands not implemented"
elif subcommand == "save":
return "Error: Region commands not implemented"
elif subcommand in ("allowf", "denyf", "get", "home", "put", "remove"):
return "Error: Region commands not implemented"
else:
return "Err - ??"
# ==================== Neighbor Commands ====================
def _cmd_neighbors(self) -> str:
"""List neighbors."""
# TODO: Get neighbors from routing table
return "Error: Not yet implemented"
def _cmd_neighbor_remove(self, command: str) -> str:
"""Remove a neighbor."""
pubkey_hex = command[16:].strip()
if not pubkey_hex:
return "ERR: Missing pubkey"
# TODO: Remove neighbor from routing table
logger.info(f"neighbor.remove: {pubkey_hex}")
return "Error: Not yet implemented"
# ==================== Temporary Radio Commands ====================
def _cmd_tempradio(self, command: str) -> str:
"""Apply temporary radio parameters."""
# Format: tempradio {freq} {bw} {sf} {cr} {timeout_mins}
parts = command[10:].split()
if len(parts) < 5:
return "Error: Expected freq bw sf cr timeout_mins"
try:
freq = float(parts[0])
bw = float(parts[1])
sf = int(parts[2])
cr = int(parts[3])
timeout_mins = int(parts[4])
# Validate
if not (300.0 <= freq <= 2500.0):
return "Error: invalid frequency"
if not (7.0 <= bw <= 500.0):
return "Error: invalid bandwidth"
if not (5 <= sf <= 12):
return "Error: invalid spreading factor"
if not (5 <= cr <= 8):
return "Error: invalid coding rate"
if timeout_mins <= 0:
return "Error: invalid timeout"
# TODO: Apply temporary radio parameters
logger.info(f"tempradio: {freq}MHz {bw}kHz SF{sf} CR4/{cr} for {timeout_mins}min")
return "Error: Not yet implemented"
except ValueError:
return "Error, invalid params"
# ==================== Logging Commands ====================
def _cmd_log(self, command: str) -> str:
"""Handle log commands."""
if command == "log start":
# TODO: Enable logging
return "Error: Not yet implemented"
elif command == "log stop":
# TODO: Disable logging
return "Error: Not yet implemented"
elif command == "log erase":
# TODO: Clear log file
return "Error: Not yet implemented"
elif command == "log":
return "Error: Use journalctl to view logs"
else:
return "Unknown log command"
+101 -11
View File
@@ -3,27 +3,39 @@ Text message (TXT_MSG) handling helper for pyMC Repeater.
This module processes incoming text messages for all managed identities
(repeater identity + identity manager identities).
Also handles CLI commands for admin users on the repeater identity.
"""
import asyncio
import logging
from pymc_core.node.handlers.text import TextMessageHandler
from .repeater_cli import RepeaterCLI
logger = logging.getLogger("TextHelper")
class TextHelper:
def __init__(self, identity_manager, packet_injector=None, acl=None, log_fn=None):
def __init__(self, identity_manager, packet_injector=None, acl_dict=None, log_fn=None,
config_path: str = None, config: dict = None, save_config_callback=None):
self.identity_manager = identity_manager
self.packet_injector = packet_injector
self.log_fn = log_fn or logger.info
self.acl = acl
self.acl_dict = acl_dict or {} # Per-identity ACLs keyed by hash_byte
# Dictionary of handlers keyed by dest_hash
self.handlers = {}
# Track repeater identity for CLI commands
self.repeater_hash = None
# Initialize CLI handler if config provided
self.cli = None
if config_path and config and save_config_callback:
self.cli = RepeaterCLI(config_path, config, save_config_callback)
logger.info("Initialized CLI handler for repeater commands")
def register_identity(
self,
@@ -33,12 +45,16 @@ class TextHelper:
radio_config=None
):
if not self.acl:
logger.warning(f"Cannot register identity '{name}': no ACL configured")
hash_byte = identity.get_public_key()[0]
# Get ACL for this identity
identity_acl = self.acl_dict.get(hash_byte)
if not identity_acl:
logger.warning(f"Cannot register identity '{name}': no ACL for hash 0x{hash_byte:02X}")
return
# Create a contacts wrapper from ACL
acl_contacts = self._create_acl_contacts_wrapper()
# Create a contacts wrapper from this identity's ACL
acl_contacts = self._create_acl_contacts_wrapper(identity_acl)
# Create TextMessageHandler for this identity
handler = TextMessageHandler(
@@ -58,15 +74,20 @@ class TextHelper:
"type": identity_type,
}
# Track repeater identity for CLI commands
if identity_type == "repeater":
self.repeater_hash = hash_byte
logger.info(f"Set repeater hash for CLI: 0x{hash_byte:02X}")
logger.info(
f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}"
)
def _create_acl_contacts_wrapper(self):
def _create_acl_contacts_wrapper(self, acl):
class ACLContactsWrapper:
def __init__(self, acl):
self._acl = acl
def __init__(self, identity_acl):
self._acl = identity_acl
@property
def contacts(self):
@@ -81,7 +102,7 @@ class TextHelper:
contact_list.append(ContactProxy(client_info))
return contact_list
return ACLContactsWrapper(self.acl)
return ACLContactsWrapper(acl)
async def process_text_packet(self, packet):
@@ -99,7 +120,42 @@ class TextHelper:
f"dest=0x{dest_hash:02X}, src=0x{src_hash:02X}"
)
# Call the handler
# Check if this is a CLI command to the repeater
if dest_hash == self.repeater_hash and self.cli:
# Try to extract message text
try:
# Assume payload format: [dest_hash, src_hash, ...message...]
message_bytes = packet.payload[2:]
message_text = message_bytes.decode('utf-8', errors='ignore').strip()
# Check if it's a CLI command
if self._is_cli_command(message_text):
# Check admin permission
is_admin = self._check_admin_permission(src_hash)
# Get sender's public key for CLI
sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Placeholder
# Handle CLI command
reply = self.cli.handle_command(
sender_pubkey=sender_pubkey,
command=message_text,
is_admin=is_admin
)
# Send reply through text handler
logger.info(f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}")
# TODO: Send reply packet
# For now, just log it
packet.mark_do_not_retransmit()
return True
except Exception as e:
logger.error(f"Error processing CLI command: {e}")
# Fall through to normal text handling
# Normal text message handling
await handler_info["handler"](packet)
# Call placeholder for custom processing
@@ -172,3 +228,37 @@ class TextHelper:
}
for hash_byte, info in self.handlers.items()
]
def _is_cli_command(self, message: str) -> bool:
"""Check if message looks like a CLI command."""
# Strip optional sequence prefix (XX|)
if len(message) > 4 and message[2] == '|':
message = message[3:].strip()
# Check for known command prefixes
command_prefixes = [
"get ", "set ", "reboot", "advert", "clock", "time ",
"password ", "clear ", "ver", "board", "neighbors", "neighbor.",
"tempradio ", "setperm ", "region", "sensor ", "gps", "log ",
"stats-", "start ota"
]
return any(message.startswith(prefix) for prefix in command_prefixes)
def _check_admin_permission(self, src_hash: int) -> bool:
"""Check if sender has admin permissions (bit 0x01)."""
# Get the repeater's ACL
repeater_acl = self.acl_dict.get(self.repeater_hash)
if not repeater_acl:
return False
# Get client by hash byte
clients = repeater_acl.get_all_clients()
for client_info in clients:
pubkey = client_info.id.get_public_key()
if pubkey[0] == src_hash:
# Check admin bit (0x01)
permissions = getattr(client_info, 'permissions', 0)
return (permissions & 0x01) != 0
return False
+18 -19
View File
@@ -158,30 +158,15 @@ class RepeaterDaemon:
else:
logger.info("Discovery response handler disabled")
# Create shared ACL for all identities
from repeater.handler_helpers.acl import ACL
repeater_config = self.config.get("repeater", {})
security_config = repeater_config.get("security", {})
shared_acl = ACL(
max_clients=security_config.get("max_clients", 50),
admin_password=security_config.get("admin_password", "admin123"),
guest_password=security_config.get("guest_password", "guest123"),
allow_read_only=security_config.get("allow_read_only", True),
)
self.acl = shared_acl
logger.info("Shared ACL initialized")
# Create login helper with shared ACL
# Create login helper (will create per-identity ACLs)
self.login_helper = LoginHelper(
identity_manager=self.identity_manager,
packet_injector=self.router.inject_packet,
acl=shared_acl,
log_fn=logger.info,
)
# Register default repeater identity
repeater_config = self.config.get("repeater", {})
self.login_helper.register_identity(
name="repeater",
identity=self.local_identity,
@@ -200,12 +185,15 @@ class RepeaterDaemon:
logger.info("Login processing helper initialized")
# Initialize text message helper (uses shared ACL as contacts)
# Initialize text message helper with per-identity ACLs
self.text_helper = TextHelper(
identity_manager=self.identity_manager,
packet_injector=self.router.inject_packet,
acl=shared_acl, # Use shared ACL as contacts database
acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs
log_fn=logger.info,
config_path=getattr(self, 'config_path', None), # For CLI to save changes
config=self.config, # For CLI to read/modify settings
save_config_callback=lambda: self._save_config(getattr(self, 'config_path', '/tmp/config.yaml')), # For CLI to persist changes
)
# Register default repeater identity for text messages
@@ -230,6 +218,17 @@ class RepeaterDaemon:
except Exception as e:
logger.error(f"Failed to initialize dispatcher: {e}")
raise
def _save_config(self, config_path: str):
"""Save configuration to file (called by CLI when settings change)."""
import yaml
try:
with open(config_path, 'w') as f:
yaml.dump(self.config, f, default_flow_style=False)
logger.info(f"Configuration saved to {config_path}")
except Exception as e:
logger.error(f"Failed to save config: {e}")
raise
async def _load_additional_identities(self):
from pymc_core import LocalIdentity