diff --git a/meshai/alert_engine.py b/meshai/alert_engine.py new file mode 100644 index 0000000..471147a --- /dev/null +++ b/meshai/alert_engine.py @@ -0,0 +1,191 @@ +"""Alert engine — detects mesh state changes and dispatches alerts.""" + +import logging +import time +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .mesh_health import MeshHealthEngine + from .mesh_reporter import MeshReporter + from .subscriptions import SubscriptionManager + +logger = logging.getLogger(__name__) + + +class AlertEngine: + """Detects mesh state changes and dispatches alerts.""" + + def __init__( + self, + health_engine: "MeshHealthEngine", + reporter: "MeshReporter", + subscription_manager: "SubscriptionManager", + critical_nodes: list[str] = None, + alert_cooldown_minutes: int = 30, + ): + self._health = health_engine + self._reporter = reporter + self._subs = subscription_manager + self._critical_nodes = set(n.upper() for n in (critical_nodes or [])) + self._cooldown_seconds = alert_cooldown_minutes * 60 + + # Previous state snapshot for change detection + self._prev_infra_online: dict[int, bool] = {} # node_num -> was_online + self._prev_battery: dict[int, float] = {} # node_num -> battery_percent + + # Cooldown tracker: condition_key -> last_alert_time + self._cooldowns: dict[str, float] = {} + + # Queued alerts for delivery + self._pending_alerts: list[dict] = [] + + def check(self) -> list[dict]: + """Compare current health to previous state. Returns list of alert dicts. + + Each alert dict: { + "type": "infra_offline" | "infra_recovery" | "battery_critical" | "critical_node_down", + "node_name": str, + "node_short": str, + "node_num": int, + "region": str, + "message": str, + "scope_type": "mesh" | "region" | "node", + "scope_value": str, + "is_critical": bool, + } + """ + health = self._health.mesh_health + if not health: + return [] + + now = time.time() + alerts = [] + + for node in health.nodes.values(): + if not node.is_infrastructure: + continue + + node_num = node.node_num + name = node.long_name or node.short_name or str(node_num) + short = node.short_name or str(node_num) + region = node.region or "Unknown" + is_critical = short.upper() in self._critical_nodes + + # --- Infrastructure offline detection --- + was_online = self._prev_infra_online.get(node_num) + is_online = node.is_online + + if was_online is not None: # Skip first run (no previous state) + if was_online and not is_online: + # Node went OFFLINE + alert_type = "critical_node_down" if is_critical else "infra_offline" + cooldown_key = f"offline_{node_num}" + + if self._check_cooldown(cooldown_key, now): + emoji = "\U0001F6A8" if is_critical else "\u274C" # 🚨 or ❌ + region_display = self._get_region_display(region) + + alerts.append({ + "type": alert_type, + "node_name": name, + "node_short": short, + "node_num": node_num, + "region": region, + "message": f"{emoji} {name} went offline in {region_display}.", + "scope_type": "region", + "scope_value": region, + "is_critical": is_critical, + }) + self._cooldowns[cooldown_key] = now + + elif not was_online and is_online: + # Node came BACK ONLINE + cooldown_key = f"recovery_{node_num}" + + if self._check_cooldown(cooldown_key, now): + region_display = self._get_region_display(region) + + alerts.append({ + "type": "infra_recovery", + "node_name": name, + "node_short": short, + "node_num": node_num, + "region": region, + "message": f"\u2705 {name} is back online in {region_display}.", # ✅ + "scope_type": "region", + "scope_value": region, + "is_critical": is_critical, + }) + self._cooldowns[cooldown_key] = now + + # --- Battery critical detection (infra only) --- + if node.battery_percent is not None and 0 < node.battery_percent <= 100: + prev_bat = self._prev_battery.get(node_num) + current_bat = node.battery_percent + + if current_bat < 10 and (prev_bat is None or prev_bat >= 10): + # Battery just dropped below 10% + cooldown_key = f"battery_{node_num}" + + if self._check_cooldown(cooldown_key, now): + region_display = self._get_region_display(region) + + alerts.append({ + "type": "battery_critical", + "node_name": name, + "node_short": short, + "node_num": node_num, + "region": region, + "message": f"\U0001F50B {name} battery critical at {current_bat:.0f}% in {region_display}.", # 🔋 + "scope_type": "region", + "scope_value": region, + "is_critical": is_critical, + }) + self._cooldowns[cooldown_key] = now + + self._prev_battery[node_num] = current_bat + + # Update state snapshot + self._prev_infra_online[node_num] = is_online + + self._pending_alerts = alerts + return alerts + + def _get_region_display(self, region: str) -> str: + """Get display name for region.""" + if not self._reporter: + return region + try: + context = self._reporter._region_context(region) + if context: + return context.split("(")[0].strip() + except Exception: + pass + return region + + def _check_cooldown(self, key: str, now: float) -> bool: + """Check if enough time has passed since last alert for this condition.""" + last = self._cooldowns.get(key, 0) + return (now - last) >= self._cooldown_seconds + + def get_pending_alerts(self) -> list[dict]: + """Get alerts pending delivery.""" + return self._pending_alerts + + def clear_pending(self): + """Clear pending alerts after delivery.""" + self._pending_alerts = [] + + def get_subscribers_for_alert(self, alert: dict) -> list[dict]: + """Find subscribers matching an alert's scope.""" + if not self._subs: + return [] + + # Get all alert subscribers + # mesh-scope subscribers get everything + # region-scope subscribers get alerts for their region + # node-scope subscribers get alerts for their specific node + return self._subs.get_alert_subscribers( + scope_type=alert.get("scope_type"), + scope_value=alert.get("scope_value"), + ) diff --git a/meshai/cli/configurator.py b/meshai/cli/configurator.py index ae2cbe5..b787a70 100644 --- a/meshai/cli/configurator.py +++ b/meshai/cli/configurator.py @@ -1,1283 +1,1351 @@ -"""Rich-based TUI configurator for MeshAI.""" - -import time -from pathlib import Path -from typing import Optional - -from rich import box -from rich.console import Console -from rich.panel import Panel -from rich.prompt import Confirm, IntPrompt, Prompt -from rich.table import Table -from rich.text import Text - -from ..config import Config, MeshSourceConfig, load_config, save_config - -console = Console() - - -class Configurator: - """Interactive configuration tool for MeshAI.""" - - def __init__(self, config_path: Optional[Path] = None): - self.config_path = config_path or Path("config.yaml") - self.config: Config = load_config(self.config_path) - self.modified = False - - def run(self) -> None: - """Run the configurator.""" - try: - self._show_welcome() - self._main_menu() - except KeyboardInterrupt: - self._handle_exit() - - def _clear(self) -> None: - """Clear the screen.""" - console.clear() - - def _show_welcome(self) -> None: - """Display welcome header.""" - self._clear() - header = Panel( - Text( - "MeshAI Configuration Tool\n" - "Configure your Meshtastic LLM assistant", - justify="center", - style="cyan", - ), - title="[yellow]Welcome[/yellow]", - border_style="blue", - ) - console.print(header) - console.print() - - def _status_icon(self, value: bool) -> str: - """Return colored status icon.""" - return "[green]✓[/green]" if value else "[red]✗[/red]" - - def _main_menu(self) -> None: - """Display and handle main menu.""" - while True: - self._clear() - self._show_header() - - table = Table(box=box.ROUNDED, show_header=False) - table.add_column("Option", style="cyan", width=4) - table.add_column("Description", style="white") - table.add_column("Status", style="dim") - - disabled_count = len(self.config.commands.disabled_commands) - cmd_status = f"{disabled_count} disabled" if disabled_count else "all enabled" - - table.add_row("1", "Bot Settings", self.config.bot.name) - table.add_row("2", "Connection", f"{self.config.connection.type}") - table.add_row("3", "LLM Backend", f"{self.config.llm.backend}/{self.config.llm.model}") - table.add_row("4", "Response Settings", f"{self.config.response.max_length}ch max") - table.add_row("5", "History & Memory", f"{self.config.history.max_messages_per_user} msgs") - table.add_row("6", "Commands", cmd_status) - ctx_status = self._status_icon(self.config.context.enabled) - table.add_row("7", "Context", f"{ctx_status} {self.config.context.max_context_items} items") - table.add_row("8", "Weather", f"{self.config.weather.primary}") - mm_status = self._status_icon(self.config.meshmonitor.enabled) - mm_url = self.config.meshmonitor.url or "[dim]not set[/dim]" - table.add_row("9", "MeshMonitor Sync", f"{mm_status} {mm_url}") - kb_status = self._status_icon(self.config.knowledge.enabled) - kb_path = self.config.knowledge.db_path or "[dim]not set[/dim]" - table.add_row("10", "Knowledge Base", f"{kb_status} {kb_path}") - - # Mesh Sources - total_sources = len(self.config.mesh_sources) - enabled_sources = sum(1 for s in self.config.mesh_sources if s.enabled) - src_status = f"{enabled_sources}/{total_sources} enabled" if total_sources else "[dim]none[/dim]" - table.add_row("11", "Mesh Sources", src_status) - - # Mesh Intelligence - mi_status = self._status_icon(self.config.mesh_intelligence.enabled) - mi_regions = len(self.config.mesh_intelligence.regions) - mi_info = f"{mi_regions} regions" if mi_regions else "[dim]auto[/dim]" - table.add_row("12", "Mesh Intelligence", f"{mi_status} {mi_info}") - - table.add_row("13", "Setup Wizard", "[dim]First-time setup[/dim]") - - console.print(table) - console.print() - - # Exit options - if self.modified: - console.print("[yellow]* Unsaved changes[/yellow]") - console.print() - console.print("[white]14. Save[/white] [dim]Save config, stay in menu[/dim]") - console.print("[green]15. Save & Restart Bot[/green] [dim]Apply changes now[/dim]") - console.print("[white]16. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]") - console.print("[white]17. Exit without Saving[/white]") - console.print() - - choice = IntPrompt.ask("Select option", default=15) - - if choice == 1: - self._bot_settings() - elif choice == 2: - self._connection_settings() - elif choice == 3: - self._llm_settings() - elif choice == 4: - self._response_settings() - elif choice == 5: - self._history_settings() - elif choice == 6: - self._command_settings() - elif choice == 7: - self._context_settings() - elif choice == 8: - self._weather_settings() - elif choice == 9: - self._meshmonitor_settings() - elif choice == 10: - self._knowledge_settings() - elif choice == 11: - self._mesh_sources_settings() - elif choice == 12: - self._mesh_intelligence_settings() - elif choice == 13: - self._setup_wizard() - elif choice == 14: - self._save_only() - elif choice == 15: - self._save_and_restart() - elif choice == 16: - self._save_restart_exit() - break - elif choice == 17: - break - - def _show_header(self) -> None: - """Show compact header with modified indicator.""" - title = "[bold cyan]MeshAI Configuration[/bold cyan]" - if self.modified: - title += " [yellow]*[/yellow]" - console.print(Panel(title, box=box.MINIMAL)) - - def _handle_exit(self) -> None: - """Handle exit (keyboard interrupt).""" - if self.modified: - if Confirm.ask("\nSave changes before exiting?", default=True): - save_config(self.config, self.config_path) - console.print("[green]Saved.[/green]") - console.print("\nGoodbye!") - - def _bot_settings(self) -> None: - """Bot settings submenu.""" - while True: - self._clear() - console.print("[bold]Bot Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Bot Name", self.config.bot.name) - table.add_row("2", "Owner", self.config.bot.owner or "[dim]not set[/dim]") - table.add_row( - "3", "Respond to DMs", self._status_icon(self.config.bot.respond_to_dms) - ) - table.add_row( - "4", "Filter BBS Protocols", self._status_icon(self.config.bot.filter_bbs_protocols) - ) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - value = Prompt.ask("Bot name", default=self.config.bot.name) - if value != self.config.bot.name: - self.config.bot.name = value - self.modified = True - elif choice == 2: - value = Prompt.ask("Owner", default=self.config.bot.owner) - if value != self.config.bot.owner: - self.config.bot.owner = value - self.modified = True - elif choice == 3: - value = Confirm.ask("Respond to DMs?", default=self.config.bot.respond_to_dms) - if value != self.config.bot.respond_to_dms: - self.config.bot.respond_to_dms = value - self.modified = True - elif choice == 4: - value = Confirm.ask("Filter BBS protocols?", default=self.config.bot.filter_bbs_protocols) - if value != self.config.bot.filter_bbs_protocols: - self.config.bot.filter_bbs_protocols = value - self.modified = True - - def _connection_settings(self) -> None: - """Connection settings submenu.""" - while True: - self._clear() - console.print("[bold]Connection Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Connection Type", self.config.connection.type) - table.add_row("2", "Serial Port", self.config.connection.serial_port) - table.add_row("3", "TCP Host", self.config.connection.tcp_host) - table.add_row("4", "TCP Port", str(self.config.connection.tcp_port)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - console.print("\n[cyan]1.[/cyan] serial - USB Serial connection") - console.print("[cyan]2.[/cyan] tcp - TCP Network connection") - sel = IntPrompt.ask("Select", default=1 if self.config.connection.type == "serial" else 2) - value = "serial" if sel == 1 else "tcp" - if value != self.config.connection.type: - self.config.connection.type = value - self.modified = True - elif choice == 2: - value = Prompt.ask("Serial port", default=self.config.connection.serial_port) - if value != self.config.connection.serial_port: - self.config.connection.serial_port = value - self.modified = True - elif choice == 3: - value = Prompt.ask("TCP host", default=self.config.connection.tcp_host) - if value != self.config.connection.tcp_host: - self.config.connection.tcp_host = value - self.modified = True - elif choice == 4: - value = IntPrompt.ask("TCP port", default=self.config.connection.tcp_port) - if value != self.config.connection.tcp_port: - self.config.connection.tcp_port = value - self.modified = True - - def _llm_settings(self) -> None: - """LLM backend settings submenu.""" - while True: - self._clear() - console.print("[bold]LLM Backend Settings[/bold]\n") - - # Mask API key for display - api_key_display = "****" + self.config.llm.api_key[-4:] if len(self.config.llm.api_key) > 4 else "[dim]not set[/dim]" - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Backend", self.config.llm.backend) - table.add_row("2", "API Key", api_key_display) - table.add_row("3", "Base URL", self.config.llm.base_url) - table.add_row("4", "Model", self.config.llm.model) - table.add_row("5", "System Prompt", f"[dim]{len(self.config.llm.system_prompt)} chars[/dim]") - table.add_row("6", "Use System Prompt", self._status_icon(self.config.llm.use_system_prompt)) - table.add_row("7", "Web Search", self._status_icon(self.config.llm.web_search)) - table.add_row("8", "Google Grounding", self._status_icon(self.config.llm.google_grounding)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - console.print("\n[cyan]1.[/cyan] openai - OpenAI / OpenAI-compatible (LiteLLM, etc)") - console.print("[cyan]2.[/cyan] anthropic - Anthropic Claude") - console.print("[cyan]3.[/cyan] google - Google Gemini") - sel = IntPrompt.ask("Select", default=1) - backends = {1: "openai", 2: "anthropic", 3: "google"} - value = backends.get(sel, "openai") - if value != self.config.llm.backend: - self.config.llm.backend = value - self.modified = True - elif choice == 2: - value = Prompt.ask("API Key", password=True) - if value: - self.config.llm.api_key = value - self.modified = True - elif choice == 3: - value = Prompt.ask("Base URL", default=self.config.llm.base_url) - if value != self.config.llm.base_url: - self.config.llm.base_url = value - self.modified = True - elif choice == 4: - value = Prompt.ask("Model", default=self.config.llm.model) - if value != self.config.llm.model: - self.config.llm.model = value - self.modified = True - elif choice == 5: - console.print("\n[dim]Current prompt:[/dim]") - console.print(self.config.llm.system_prompt or "(empty)") - console.print() - if Confirm.ask("Edit system prompt?", default=False): - console.print("[dim]Enter new prompt, or leave empty to clear[/dim]") - value = Prompt.ask("New system prompt", default="") - if value != self.config.llm.system_prompt: - self.config.llm.system_prompt = value - self.modified = True - elif choice == 6: - self.config.llm.use_system_prompt = not self.config.llm.use_system_prompt - self.modified = True - elif choice == 7: - self.config.llm.web_search = not self.config.llm.web_search - self.modified = True - elif choice == 8: - if self.config.llm.backend == "google": - self.config.llm.google_grounding = not self.config.llm.google_grounding - self.modified = True - else: - console.print("[yellow]Google grounding is only available with the google backend.[/yellow]") - input("Press Enter to continue...") - - def _command_settings(self) -> None: - """Command settings submenu.""" - # All built-in commands - builtin = ["help", "ping", "status", "weather", "reset", "clear"] - - while True: - self._clear() - console.print("[bold]Command Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Command", style="white") - table.add_column("Status", style="green") - - disabled = set(c.lower() for c in self.config.commands.disabled_commands) - for i, cmd in enumerate(builtin, 1): - status = "[red]disabled[/red]" if cmd in disabled else "[green]enabled[/green]" - table.add_row(str(i), f"!{cmd}", status) - - table.add_row("", "", "") - table.add_row("7", "Command Prefix", self.config.commands.prefix) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif 1 <= choice <= len(builtin): - cmd = builtin[choice - 1] - if cmd in disabled: - self.config.commands.disabled_commands.remove(cmd) - console.print(f"[green]!{cmd} enabled[/green]") - else: - self.config.commands.disabled_commands.append(cmd) - console.print(f"[red]!{cmd} disabled[/red]") - self.modified = True - elif choice == 7: - value = Prompt.ask("Command prefix", default=self.config.commands.prefix) - if value != self.config.commands.prefix: - self.config.commands.prefix = value - self.modified = True - - def _context_settings(self) -> None: - """Mesh context settings submenu.""" - while True: - self._clear() - console.print("[bold]Mesh Context Settings[/bold]\n") - console.print("[dim]Passively observes channel traffic to give the LLM situational awareness.[/dim]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - channels = self.config.context.observe_channels - ch_display = ", ".join(str(c) for c in channels) if channels else "[dim]all[/dim]" - nodes = self.config.context.ignore_nodes - node_display = ", ".join(nodes) if nodes else "[dim]none[/dim]" - age_days = self.config.context.max_age // 86400 - - table.add_row("1", "Enabled", self._status_icon(self.config.context.enabled)) - table.add_row("2", "Observe Channels", ch_display) - table.add_row("3", "Ignore Nodes", node_display) - table.add_row("4", "Max Age", f"{age_days}d") - table.add_row("5", "Max Context Items", str(self.config.context.max_context_items)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - self.config.context.enabled = not self.config.context.enabled - self.modified = True - elif choice == 2: - console.print("\n[dim]Enter channel indices separated by commas, or leave empty for all.[/dim]") - value = Prompt.ask("Channels", default=", ".join(str(c) for c in channels)) - parsed = [int(x.strip()) for x in value.split(",") if x.strip().isdigit()] if value.strip() else [] - if parsed != self.config.context.observe_channels: - self.config.context.observe_channels = parsed - self.modified = True - elif choice == 3: - console.print("\n[dim]Enter node IDs separated by commas, or leave empty for none.[/dim]") - value = Prompt.ask("Node IDs", default=", ".join(nodes)) - parsed = [x.strip() for x in value.split(",") if x.strip()] if value.strip() else [] - if parsed != self.config.context.ignore_nodes: - self.config.context.ignore_nodes = parsed - self.modified = True - elif choice == 4: - value = IntPrompt.ask("Max age (days)", default=age_days) - seconds = value * 86400 - if seconds != self.config.context.max_age: - self.config.context.max_age = seconds - self.modified = True - elif choice == 5: - value = IntPrompt.ask("Max context items", default=self.config.context.max_context_items) - if value != self.config.context.max_context_items: - self.config.context.max_context_items = value - self.modified = True - - def _weather_settings(self) -> None: - """Weather settings submenu.""" - while True: - self._clear() - console.print("[bold]Weather Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Primary Provider", self.config.weather.primary) - table.add_row("2", "Fallback Provider", self.config.weather.fallback) - table.add_row("3", "Default Location", self.config.weather.default_location or "[dim]not set[/dim]") - table.add_row("4", "Open-Meteo URL", self.config.weather.openmeteo.url) - table.add_row("5", "wttr.in URL", self.config.weather.wttr.url) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - console.print("\n[cyan]1.[/cyan] openmeteo - Open-Meteo API (free, no key)") - console.print("[cyan]2.[/cyan] wttr - wttr.in (free, simple)") - console.print("[cyan]3.[/cyan] llm - Use LLM with web search") - sel = IntPrompt.ask("Select", default=1) - providers = {1: "openmeteo", 2: "wttr", 3: "llm"} - value = providers.get(sel, "openmeteo") - if value != self.config.weather.primary: - self.config.weather.primary = value - self.modified = True - elif choice == 2: - console.print("\n[cyan]1.[/cyan] openmeteo") - console.print("[cyan]2.[/cyan] wttr") - console.print("[cyan]3.[/cyan] llm") - console.print("[cyan]4.[/cyan] none - No fallback") - sel = IntPrompt.ask("Select", default=3) - providers = {1: "openmeteo", 2: "wttr", 3: "llm", 4: "none"} - value = providers.get(sel, "llm") - if value != self.config.weather.fallback: - self.config.weather.fallback = value - self.modified = True - elif choice == 3: - value = Prompt.ask("Default location", default=self.config.weather.default_location) - if value != self.config.weather.default_location: - self.config.weather.default_location = value - self.modified = True - elif choice == 4: - value = Prompt.ask("Open-Meteo URL", default=self.config.weather.openmeteo.url) - if value != self.config.weather.openmeteo.url: - self.config.weather.openmeteo.url = value - self.modified = True - elif choice == 5: - value = Prompt.ask("wttr.in URL", default=self.config.weather.wttr.url) - if value != self.config.weather.wttr.url: - self.config.weather.wttr.url = value - self.modified = True - - def _response_settings(self) -> None: - """Response settings submenu.""" - while True: - self._clear() - console.print("[bold]Response Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Min Delay (seconds)", str(self.config.response.delay_min)) - table.add_row("2", "Max Delay (seconds)", str(self.config.response.delay_max)) - table.add_row("3", "Max Length (chars)", str(self.config.response.max_length)) - table.add_row("4", "Max Messages", str(self.config.response.max_messages)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - value = float(Prompt.ask("Min delay", default=str(self.config.response.delay_min))) - if value != self.config.response.delay_min: - self.config.response.delay_min = value - self.modified = True - elif choice == 2: - value = float(Prompt.ask("Max delay", default=str(self.config.response.delay_max))) - if value != self.config.response.delay_max: - self.config.response.delay_max = value - self.modified = True - elif choice == 3: - value = IntPrompt.ask("Max length", default=self.config.response.max_length) - if value != self.config.response.max_length: - self.config.response.max_length = value - self.modified = True - elif choice == 4: - value = IntPrompt.ask("Max messages", default=self.config.response.max_messages) - if value != self.config.response.max_messages: - self.config.response.max_messages = value - self.modified = True - - def _history_settings(self) -> None: - """History settings submenu.""" - while True: - self._clear() - console.print("[bold]History & Memory Settings[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - timeout_hours = self.config.history.conversation_timeout // 3600 - table.add_row("1", "Database File", self.config.history.database) - table.add_row("2", "Max Messages Per User", str(self.config.history.max_messages_per_user)) - table.add_row("3", "Conversation Timeout", f"{timeout_hours}h") - table.add_row("4", "Auto Cleanup", self._status_icon(self.config.history.auto_cleanup)) - table.add_row("5", "Max Age (days)", str(self.config.history.max_age_days)) - table.add_row("", "[bold]Memory[/bold]", "") - table.add_row("6", "Memory Enabled", self._status_icon(self.config.memory.enabled)) - table.add_row("7", "Window Size", str(self.config.memory.window_size)) - table.add_row("8", "Summarize Threshold", str(self.config.memory.summarize_threshold)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - value = Prompt.ask("Database file", default=self.config.history.database) - if value != self.config.history.database: - self.config.history.database = value - self.modified = True - elif choice == 2: - value = IntPrompt.ask( - "Max messages per user", default=self.config.history.max_messages_per_user - ) - if value != self.config.history.max_messages_per_user: - self.config.history.max_messages_per_user = value - self.modified = True - elif choice == 3: - value = IntPrompt.ask("Timeout (hours)", default=timeout_hours) - seconds = value * 3600 - if seconds != self.config.history.conversation_timeout: - self.config.history.conversation_timeout = seconds - self.modified = True - elif choice == 4: - value = Confirm.ask("Enable auto cleanup?", default=self.config.history.auto_cleanup) - if value != self.config.history.auto_cleanup: - self.config.history.auto_cleanup = value - self.modified = True - elif choice == 5: - value = IntPrompt.ask("Max age (days)", default=self.config.history.max_age_days) - if value != self.config.history.max_age_days: - self.config.history.max_age_days = value - self.modified = True - elif choice == 6: - value = Confirm.ask("Enable memory?", default=self.config.memory.enabled) - if value != self.config.memory.enabled: - self.config.memory.enabled = value - self.modified = True - elif choice == 7: - value = IntPrompt.ask("Window size", default=self.config.memory.window_size) - if value != self.config.memory.window_size: - self.config.memory.window_size = value - self.modified = True - elif choice == 8: - value = IntPrompt.ask("Summarize threshold", default=self.config.memory.summarize_threshold) - if value != self.config.memory.summarize_threshold: - self.config.memory.summarize_threshold = value - self.modified = True - - def _meshmonitor_settings(self) -> None: - """MeshMonitor sync settings submenu.""" - while True: - self._clear() - console.print("[bold]MeshMonitor Sync Settings[/bold]\n") - console.print("[dim]Sync auto-responder triggers from MeshMonitor to avoid duplicate responses.[/dim]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Enabled", self._status_icon(self.config.meshmonitor.enabled)) - table.add_row("2", "MeshMonitor URL", self.config.meshmonitor.url or "[dim]not set[/dim]") - table.add_row("3", "Inject into Prompt", self._status_icon(self.config.meshmonitor.inject_into_prompt)) - table.add_row("4", "Refresh Interval", f"{self.config.meshmonitor.refresh_interval}s") - table.add_row("5", "View Triggers", "[dim]Fetch and display[/dim]") - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - self.config.meshmonitor.enabled = not self.config.meshmonitor.enabled - self.modified = True - elif choice == 2: - value = Prompt.ask("MeshMonitor URL (e.g., http://100.64.0.11:3333)", - default=self.config.meshmonitor.url) - if value != self.config.meshmonitor.url: - self.config.meshmonitor.url = value - self.modified = True - elif choice == 3: - self.config.meshmonitor.inject_into_prompt = not self.config.meshmonitor.inject_into_prompt - self.modified = True - elif choice == 4: - value = IntPrompt.ask("Refresh interval (seconds)", default=self.config.meshmonitor.refresh_interval) - if value != self.config.meshmonitor.refresh_interval: - self.config.meshmonitor.refresh_interval = value - self.modified = True - elif choice == 5: - self._view_meshmonitor_triggers() - - def _view_meshmonitor_triggers(self) -> None: - """Fetch and display MeshMonitor triggers.""" - self._clear() - console.print("[bold]MeshMonitor Triggers[/bold]\n") - - if not self.config.meshmonitor.url: - console.print("[yellow]MeshMonitor URL not configured.[/yellow]") - input("\nPress Enter to continue...") - return - - console.print(f"[dim]Fetching from {self.config.meshmonitor.url}...[/dim]\n") - - try: - from ..meshmonitor import MeshMonitorSync - sync = MeshMonitorSync(self.config.meshmonitor.url) - count = sync.load() - - if count == 0: - if sync.last_error: - console.print(f"[red]Error: {sync.last_error}[/red]") - else: - console.print("[yellow]No triggers configured in MeshMonitor.[/yellow]") - else: - console.print(f"[green]Loaded {count} triggers:[/green]\n") - for trigger in sync.raw_triggers: - console.print(f" [cyan]{trigger}[/cyan]") - except Exception as e: - console.print(f"[red]Failed to fetch triggers: {e}[/red]") - - input("\nPress Enter to continue...") - - - def _knowledge_settings(self) -> None: - """Knowledge base settings submenu.""" - while True: - self._clear() - console.print("[bold]Knowledge Base Settings[/bold]\n") - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Enabled", self._status_icon(self.config.knowledge.enabled)) - table.add_row("2", "Database Path", self.config.knowledge.db_path or "[dim]not set[/dim]") - table.add_row("3", "Results Count", str(self.config.knowledge.top_k)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - value = Confirm.ask("Enable knowledge base?", default=self.config.knowledge.enabled) - if value != self.config.knowledge.enabled: - self.config.knowledge.enabled = value - self.modified = True - elif choice == 2: - value = Prompt.ask("Database path", default=self.config.knowledge.db_path) - if value != self.config.knowledge.db_path: - self.config.knowledge.db_path = value - self.modified = True - elif choice == 3: - value = IntPrompt.ask("Results count (top_k)", default=self.config.knowledge.top_k) - if value != self.config.knowledge.top_k: - self.config.knowledge.top_k = value - self.modified = True - - def _mesh_sources_settings(self) -> None: - """Mesh data sources settings submenu.""" - while True: - self._clear() - console.print("[bold]Mesh Data Sources[/bold]\n") - console.print("[dim]Connect to Meshview and/or MeshMonitor instances for live mesh data.[/dim]\n") - - # Display configured sources - if self.config.mesh_sources: - table = Table(box=box.ROUNDED) - table.add_column("#", style="cyan", width=3) - table.add_column("Name", style="white") - table.add_column("Type", style="blue") - table.add_column("URL", style="dim") - table.add_column("Enabled", style="green") - - for i, src in enumerate(self.config.mesh_sources, 1): - table.add_row( - str(i), - src.name, - src.type, - src.url[:40] + "..." if len(src.url) > 40 else src.url, - self._status_icon(src.enabled), - ) - console.print(table) - else: - console.print("[dim]No sources configured.[/dim]") - - console.print() - console.print("[cyan]1.[/cyan] Add source") - console.print("[cyan]2.[/cyan] Edit source") - console.print("[cyan]3.[/cyan] Remove source") - console.print("[cyan]4.[/cyan] Test source") - console.print("[cyan]0.[/cyan] Back") - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - self._add_mesh_source() - elif choice == 2: - self._edit_mesh_source() - elif choice == 3: - self._remove_mesh_source() - elif choice == 4: - self._test_mesh_source() - - def _add_mesh_source(self) -> None: - """Add a new mesh data source.""" - self._clear() - console.print("[bold]Add Mesh Source[/bold]\n") - - # Get name - existing_names = {s.name for s in self.config.mesh_sources} - while True: - name = Prompt.ask("Source name (unique identifier)") - if not name: - console.print("[yellow]Name is required.[/yellow]") - continue - if name in existing_names: - console.print(f"[yellow]Name '{name}' already exists. Choose another.[/yellow]") - continue - break - - # Get type - console.print("\n[cyan]1.[/cyan] meshview - Meshview instance") - console.print("[cyan]2.[/cyan] meshmonitor - MeshMonitor instance") - type_choice = IntPrompt.ask("Source type", default=1) - source_type = "meshview" if type_choice == 1 else "meshmonitor" - - # Get URL - url = Prompt.ask("URL (e.g., https://meshview.example.com or http://192.168.1.100:3333)") - - # Get API token (MeshMonitor only) - api_token = "" - if source_type == "meshmonitor": - console.print("\n[dim]API token is required for MeshMonitor. Use ${ENV_VAR} for env vars.[/dim]") - api_token = Prompt.ask("API token", default="") - - # Get refresh interval - refresh_interval = IntPrompt.ask("Refresh interval (seconds)", default=300) - - # Create and add source - source = MeshSourceConfig( - name=name, - type=source_type, - url=url, - api_token=api_token, - refresh_interval=refresh_interval, - enabled=True, - ) - self.config.mesh_sources.append(source) - self.modified = True - - console.print(f"\n[green]Source '{name}' added.[/green]") - input("Press Enter to continue...") - - def _edit_mesh_source(self) -> None: - """Edit an existing mesh data source.""" - if not self.config.mesh_sources: - console.print("[yellow]No sources to edit.[/yellow]") - input("\nPress Enter to continue...") - return - - self._clear() - console.print("[bold]Edit Mesh Source[/bold]\n") - - # Show list - for i, src in enumerate(self.config.mesh_sources, 1): - status = "[green]enabled[/green]" if src.enabled else "[red]disabled[/red]" - console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type}) - {status}") - - console.print("[cyan]0.[/cyan] Cancel") - console.print() - - choice = IntPrompt.ask("Select source to edit", default=0) - if choice == 0 or choice > len(self.config.mesh_sources): - return - - src = self.config.mesh_sources[choice - 1] - - while True: - self._clear() - console.print(f"[bold]Edit Source: {src.name}[/bold]\n") - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Name", src.name) - table.add_row("2", "Type", src.type) - table.add_row("3", "URL", src.url) - if src.type == "meshmonitor": - token_display = "****" + src.api_token[-4:] if len(src.api_token) > 4 else src.api_token or "[dim]not set[/dim]" - table.add_row("4", "API Token", token_display) - table.add_row("5", "Refresh Interval", f"{src.refresh_interval}s") - table.add_row("6", "Enabled", self._status_icon(src.enabled)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - opt = IntPrompt.ask("Select option", default=0) - - if opt == 0: - return - elif opt == 1: - existing_names = {s.name for s in self.config.mesh_sources if s != src} - value = Prompt.ask("Name", default=src.name) - if value and value not in existing_names: - src.name = value - self.modified = True - elif value in existing_names: - console.print("[yellow]Name already exists.[/yellow]") - elif opt == 2: - console.print("\n[cyan]1.[/cyan] meshview") - console.print("[cyan]2.[/cyan] meshmonitor") - t = IntPrompt.ask("Type", default=1 if src.type == "meshview" else 2) - new_type = "meshview" if t == 1 else "meshmonitor" - if new_type != src.type: - src.type = new_type - self.modified = True - elif opt == 3: - value = Prompt.ask("URL", default=src.url) - if value != src.url: - src.url = value - self.modified = True - elif opt == 4 and src.type == "meshmonitor": - value = Prompt.ask("API Token", default=src.api_token) - if value != src.api_token: - src.api_token = value - self.modified = True - elif opt == 5: - value = IntPrompt.ask("Refresh interval (seconds)", default=src.refresh_interval) - if value != src.refresh_interval: - src.refresh_interval = value - self.modified = True - elif opt == 6: - src.enabled = not src.enabled - self.modified = True - - def _remove_mesh_source(self) -> None: - """Remove a mesh data source.""" - if not self.config.mesh_sources: - console.print("[yellow]No sources to remove.[/yellow]") - input("\nPress Enter to continue...") - return - - self._clear() - console.print("[bold]Remove Mesh Source[/bold]\n") - - # Show list - for i, src in enumerate(self.config.mesh_sources, 1): - console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type})") - - console.print("[cyan]0.[/cyan] Cancel") - console.print() - - choice = IntPrompt.ask("Select source to remove", default=0) - if choice == 0 or choice > len(self.config.mesh_sources): - return - - src = self.config.mesh_sources[choice - 1] - if Confirm.ask(f"Remove source '{src.name}'?", default=False): - self.config.mesh_sources.pop(choice - 1) - self.modified = True - console.print(f"[green]Source '{src.name}' removed.[/green]") - input("Press Enter to continue...") - - def _test_mesh_source(self) -> None: - """Test a mesh data source connection.""" - if not self.config.mesh_sources: - console.print("[yellow]No sources to test.[/yellow]") - input("\nPress Enter to continue...") - return - - self._clear() - console.print("[bold]Test Mesh Source[/bold]\n") - - # Show list - for i, src in enumerate(self.config.mesh_sources, 1): - console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type})") - - console.print("[cyan]0.[/cyan] Cancel") - console.print() - - choice = IntPrompt.ask("Select source to test", default=0) - if choice == 0 or choice > len(self.config.mesh_sources): - return - - src = self.config.mesh_sources[choice - 1] - console.print(f"\n[dim]Testing {src.name} ({src.url})...[/dim]\n") - - try: - if src.type == "meshview": - from ..sources.meshview import MeshviewSource - source = MeshviewSource(url=src.url, refresh_interval=src.refresh_interval) - else: - from ..sources.meshmonitor_data import MeshMonitorDataSource - source = MeshMonitorDataSource( - url=src.url, - api_token=src.api_token, - refresh_interval=src.refresh_interval, - ) - - success = source.fetch_all() - - if success: - console.print("[green]Connection successful![/green]\n") - console.print(f" Nodes: {len(source.nodes)}") - if src.type == "meshview": - console.print(f" Edges: {len(source.edges)}") - console.print(f" Stats: {'loaded' if source.stats else 'none'}") - console.print(f" Counts: {'loaded' if source.counts else 'none'}") - else: - console.print(f" Channels: {len(source.channels)}") - console.print(f" Telemetry: {len(source.telemetry)}") - console.print(f" Traceroutes: {len(source.traceroutes)}") - console.print(f" Packets: {len(source.packets)}") - else: - console.print(f"[red]Connection failed: {source.last_error}[/red]") - - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - - input("\nPress Enter to continue...") - - def _mesh_intelligence_settings(self) -> None: - """Mesh intelligence settings submenu.""" - while True: - self._clear() - console.print("[bold]Mesh Intelligence Settings[/bold]\n") - console.print("[dim]Region-based health scoring for mesh analysis.[/dim]\n") - - mi = self.config.mesh_intelligence - - table = Table(box=box.ROUNDED) - table.add_column("Option", style="cyan", width=4) - table.add_column("Setting", style="white") - table.add_column("Value", style="green") - - table.add_row("1", "Enabled", self._status_icon(mi.enabled)) - table.add_row("2", "Regions", f"{len(mi.regions)} defined" if mi.regions else "[dim]none[/dim]") - table.add_row("3", "Locality Radius (miles)", str(mi.locality_radius_miles)) - table.add_row("4", "Offline Threshold (hours)", str(mi.offline_threshold_hours)) - table.add_row("5", "Packet Threshold (24h)", str(mi.packet_threshold)) - table.add_row("6", "Battery Warning (%)", str(mi.battery_warning_percent)) - table.add_row("0", "Back", "") - - console.print(table) - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - mi.enabled = not mi.enabled - self.modified = True - elif choice == 2: - self._edit_regions() - elif choice == 3: - value = float(Prompt.ask("Locality radius (miles)", default=str(mi.locality_radius_miles))) - if value != mi.locality_radius_miles: - mi.locality_radius_miles = value - self.modified = True - elif choice == 4: - value = IntPrompt.ask("Offline threshold (hours)", default=mi.offline_threshold_hours) - if value != mi.offline_threshold_hours: - mi.offline_threshold_hours = value - self.modified = True - elif choice == 5: - value = IntPrompt.ask("Packet threshold (24h)", default=mi.packet_threshold) - if value != mi.packet_threshold: - mi.packet_threshold = value - self.modified = True - elif choice == 6: - value = IntPrompt.ask("Battery warning (%)", default=mi.battery_warning_percent) - if value != mi.battery_warning_percent: - mi.battery_warning_percent = value - self.modified = True - - def _edit_regions(self) -> None: - """Edit region anchor points.""" - from ..config import RegionAnchor - - while True: - self._clear() - console.print("[bold]Region Anchors[/bold]\n") - console.print("[dim]Define region center points. Nodes are assigned to nearest region.[/dim]\n") - - regions = self.config.mesh_intelligence.regions - - if regions: - table = Table(box=box.ROUNDED) - table.add_column("#", style="cyan", width=3) - table.add_column("Name", style="white") - table.add_column("Latitude", style="green") - table.add_column("Longitude", style="green") - - for i, r in enumerate(regions, 1): - table.add_row(str(i), r.name, f"{r.lat:.4f}", f"{r.lon:.4f}") - console.print(table) - else: - console.print("[dim]No regions defined.[/dim]") - - console.print() - console.print("[cyan]1.[/cyan] Add region") - console.print("[cyan]2.[/cyan] Edit region") - console.print("[cyan]3.[/cyan] Remove region") - console.print("[cyan]4.[/cyan] Load ID/UT defaults") - console.print("[cyan]0.[/cyan] Back") - console.print() - - choice = IntPrompt.ask("Select option", default=0) - - if choice == 0: - return - elif choice == 1: - name = Prompt.ask("Region name") - if name: - lat = float(Prompt.ask("Center latitude", default="0.0")) - lon = float(Prompt.ask("Center longitude", default="0.0")) - self.config.mesh_intelligence.regions.append( - RegionAnchor(name=name, lat=lat, lon=lon) - ) - self.modified = True - console.print(f"[green]Added: {name} @ {lat}, {lon}[/green]") - input("Press Enter to continue...") - elif choice == 2: - if not regions: - console.print("[yellow]No regions to edit.[/yellow]") - input("\nPress Enter to continue...") - continue - console.print() - for i, r in enumerate(regions, 1): - console.print(f"[cyan]{i}.[/cyan] {r.name}") - console.print("[cyan]0.[/cyan] Cancel") - idx = IntPrompt.ask("Select region", default=0) - if 1 <= idx <= len(regions): - r = regions[idx - 1] - r.name = Prompt.ask("Name", default=r.name) - r.lat = float(Prompt.ask("Latitude", default=str(r.lat))) - r.lon = float(Prompt.ask("Longitude", default=str(r.lon))) - self.modified = True - elif choice == 3: - if not regions: - console.print("[yellow]No regions to remove.[/yellow]") - input("\nPress Enter to continue...") - continue - console.print() - for i, r in enumerate(regions, 1): - console.print(f"[cyan]{i}.[/cyan] {r.name}") - console.print("[cyan]0.[/cyan] Cancel") - idx = IntPrompt.ask("Select region to remove", default=0) - if 1 <= idx <= len(regions): - removed = self.config.mesh_intelligence.regions.pop(idx - 1) - self.modified = True - console.print(f"[green]Removed: {removed.name}[/green]") - input("Press Enter to continue...") - elif choice == 4: - # Load ID/UT region defaults - self.config.mesh_intelligence.regions = [ - # Idaho - RegionAnchor(name="Northern ID", lat=47.6777, lon=-116.7805), # Coeur d'Alene - RegionAnchor(name="Central ID", lat=45.1757, lon=-113.8958), # Salmon - RegionAnchor(name="South Western ID", lat=43.6150, lon=-116.2023), # Boise - RegionAnchor(name="South Central ID", lat=42.5558, lon=-114.4701), # Twin Falls - RegionAnchor(name="South Eastern ID", lat=43.4926, lon=-112.0341), # Idaho Falls - # Utah - RegionAnchor(name="Northern UT", lat=41.2230, lon=-111.9738), # Ogden - RegionAnchor(name="Central UT", lat=40.7608, lon=-111.8910), # Salt Lake City - RegionAnchor(name="Eastern UT", lat=40.4555, lon=-109.5287), # Vernal - RegionAnchor(name="Western UT", lat=40.5308, lon=-112.2983), # Tooele - RegionAnchor(name="Southern UT", lat=37.0965, lon=-113.5684), # St. George - ] - self.modified = True - console.print("[green]Loaded 10 ID/UT region defaults.[/green]") - input("Press Enter to continue...") - - def _setup_wizard(self) -> None: - """First-time setup wizard.""" - self._clear() - console.print(Panel("[bold]MeshAI Setup Wizard[/bold]", style="cyan")) - console.print("\nThis wizard will help you configure MeshAI.\n") - - # Step 1: Bot identity - console.print("[bold cyan]Step 1: Bot Identity[/bold cyan]") - self.config.bot.name = Prompt.ask("Bot name", default="ai") - self.config.bot.owner = Prompt.ask("Your name/callsign", default="") - console.print() - - # Step 2: Connection - console.print("[bold cyan]Step 2: Meshtastic Connection[/bold cyan]") - console.print("[cyan]1.[/cyan] serial - USB Serial") - console.print("[cyan]2.[/cyan] tcp - Network TCP") - sel = IntPrompt.ask("Connection type", default=1) - self.config.connection.type = "serial" if sel == 1 else "tcp" - - if self.config.connection.type == "serial": - self.config.connection.serial_port = Prompt.ask( - "Serial port", default="/dev/ttyUSB0" - ) - else: - self.config.connection.tcp_host = Prompt.ask( - "TCP host", default="192.168.1.100" - ) - self.config.connection.tcp_port = IntPrompt.ask("TCP port", default=4403) - console.print() - - # Step 3: LLM - console.print("[bold cyan]Step 3: LLM Backend[/bold cyan]") - console.print("[cyan]1.[/cyan] openai - OpenAI / OpenAI-compatible") - console.print("[cyan]2.[/cyan] anthropic - Anthropic Claude") - console.print("[cyan]3.[/cyan] google - Google Gemini") - sel = IntPrompt.ask("Backend", default=1) - backends = {1: "openai", 2: "anthropic", 3: "google"} - self.config.llm.backend = backends.get(sel, "openai") - - self.config.llm.api_key = Prompt.ask("API Key", password=True) - - if self.config.llm.backend == "openai": - if Confirm.ask("Using local/self-hosted API?", default=False): - self.config.llm.base_url = Prompt.ask( - "Base URL", default="http://localhost:4000/v1" - ) - - self.config.llm.model = Prompt.ask("Model", default="gpt-4o-mini") - console.print() - - # Step 4: Weather (optional) - console.print("[bold cyan]Step 4: Weather (optional)[/bold cyan]") - self.config.weather.default_location = Prompt.ask( - "Default location (for !weather)", default="" - ) - console.print() - - self.modified = True - console.print("[green]Setup complete![/green]") - console.print("Press Enter to return to main menu...") - input() - - def _save_only(self) -> None: - """Save config and stay in menu.""" - save_config(self.config, self.config_path) - console.print(f"[green]Configuration saved to {self.config_path}[/green]") - self.modified = False - input("Press Enter to continue...") - - def _save_and_restart(self) -> None: - """Save config and signal bot to restart, stay in menu.""" - self._clear() - console.print("[cyan]Saving configuration...[/cyan]") - save_config(self.config, self.config_path) - console.print("[green]Configuration saved![/green]") - self.modified = False - console.print() - - # Write restart signal file (docker-entrypoint watches for this) - restart_file = Path("/tmp/meshai_restart") - try: - restart_file.touch() - console.print("[cyan]Bot restart signal sent.[/cyan]") - console.print() - console.print("The bot will restart momentarily to apply changes.") - except Exception as e: - console.print(f"[yellow]Could not signal restart: {e}[/yellow]") - - input("\nPress Enter to continue...") - - def _save_restart_exit(self) -> None: - """Save config, signal bot restart, and exit config tool.""" - console.print("[cyan]Saving configuration...[/cyan]") - save_config(self.config, self.config_path) - console.print("[green]Configuration saved![/green]") - self.modified = False - - # Write restart signal file - restart_file = Path("/tmp/meshai_restart") - try: - restart_file.touch() - console.print("[cyan]Bot restart signal sent.[/cyan]") - except Exception as e: - console.print(f"[yellow]Could not signal restart: {e}[/yellow]") - - console.print("\nGoodbye!") - - -def run_configurator(config_path: Optional[Path] = None) -> None: - """Entry point for configurator.""" - configurator = Configurator(config_path) - configurator.run() +"""Rich-based TUI configurator for MeshAI.""" + +import time +from pathlib import Path +from typing import Optional + +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Confirm, IntPrompt, Prompt +from rich.table import Table +from rich.text import Text + +from ..config import Config, MeshSourceConfig, load_config, save_config + +console = Console() + + +class Configurator: + """Interactive configuration tool for MeshAI.""" + + def __init__(self, config_path: Optional[Path] = None): + self.config_path = config_path or Path("config.yaml") + self.config: Config = load_config(self.config_path) + self.modified = False + + def run(self) -> None: + """Run the configurator.""" + try: + self._show_welcome() + self._main_menu() + except KeyboardInterrupt: + self._handle_exit() + + def _clear(self) -> None: + """Clear the screen.""" + console.clear() + + def _show_welcome(self) -> None: + """Display welcome header.""" + self._clear() + header = Panel( + Text( + "MeshAI Configuration Tool\n" + "Configure your Meshtastic LLM assistant", + justify="center", + style="cyan", + ), + title="[yellow]Welcome[/yellow]", + border_style="blue", + ) + console.print(header) + console.print() + + def _status_icon(self, value: bool) -> str: + """Return colored status icon.""" + return "[green]✓[/green]" if value else "[red]✗[/red]" + + def _main_menu(self) -> None: + """Display and handle main menu.""" + while True: + self._clear() + self._show_header() + + table = Table(box=box.ROUNDED, show_header=False) + table.add_column("Option", style="cyan", width=4) + table.add_column("Description", style="white") + table.add_column("Status", style="dim") + + disabled_count = len(self.config.commands.disabled_commands) + cmd_status = f"{disabled_count} disabled" if disabled_count else "all enabled" + + table.add_row("1", "Bot Settings", self.config.bot.name) + table.add_row("2", "Connection", f"{self.config.connection.type}") + table.add_row("3", "LLM Backend", f"{self.config.llm.backend}/{self.config.llm.model}") + table.add_row("4", "Response Settings", f"{self.config.response.max_length}ch max") + table.add_row("5", "History & Memory", f"{self.config.history.max_messages_per_user} msgs") + table.add_row("6", "Commands", cmd_status) + ctx_status = self._status_icon(self.config.context.enabled) + table.add_row("7", "Context", f"{ctx_status} {self.config.context.max_context_items} items") + table.add_row("8", "Weather", f"{self.config.weather.primary}") + mm_status = self._status_icon(self.config.meshmonitor.enabled) + mm_url = self.config.meshmonitor.url or "[dim]not set[/dim]" + table.add_row("9", "MeshMonitor Sync", f"{mm_status} {mm_url}") + kb_status = self._status_icon(self.config.knowledge.enabled) + kb_path = self.config.knowledge.db_path or "[dim]not set[/dim]" + table.add_row("10", "Knowledge Base", f"{kb_status} {kb_path}") + + # Mesh Sources + total_sources = len(self.config.mesh_sources) + enabled_sources = sum(1 for s in self.config.mesh_sources if s.enabled) + src_status = f"{enabled_sources}/{total_sources} enabled" if total_sources else "[dim]none[/dim]" + table.add_row("11", "Mesh Sources", src_status) + + # Mesh Intelligence + mi_status = self._status_icon(self.config.mesh_intelligence.enabled) + mi_regions = len(self.config.mesh_intelligence.regions) + mi_info = f"{mi_regions} regions" if mi_regions else "[dim]auto[/dim]" + table.add_row("12", "Mesh Intelligence", f"{mi_status} {mi_info}") + + table.add_row("13", "Setup Wizard", "[dim]First-time setup[/dim]") + + console.print(table) + console.print() + + # Exit options + if self.modified: + console.print("[yellow]* Unsaved changes[/yellow]") + console.print() + console.print("[white]14. Save[/white] [dim]Save config, stay in menu[/dim]") + console.print("[green]15. Save & Restart Bot[/green] [dim]Apply changes now[/dim]") + console.print("[white]16. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]") + console.print("[white]17. Exit without Saving[/white]") + console.print() + + choice = IntPrompt.ask("Select option", default=15) + + if choice == 1: + self._bot_settings() + elif choice == 2: + self._connection_settings() + elif choice == 3: + self._llm_settings() + elif choice == 4: + self._response_settings() + elif choice == 5: + self._history_settings() + elif choice == 6: + self._command_settings() + elif choice == 7: + self._context_settings() + elif choice == 8: + self._weather_settings() + elif choice == 9: + self._meshmonitor_settings() + elif choice == 10: + self._knowledge_settings() + elif choice == 11: + self._mesh_sources_settings() + elif choice == 12: + self._mesh_intelligence_settings() + elif choice == 13: + self._setup_wizard() + elif choice == 14: + self._save_only() + elif choice == 15: + self._save_and_restart() + elif choice == 16: + self._save_restart_exit() + break + elif choice == 17: + break + + def _show_header(self) -> None: + """Show compact header with modified indicator.""" + title = "[bold cyan]MeshAI Configuration[/bold cyan]" + if self.modified: + title += " [yellow]*[/yellow]" + console.print(Panel(title, box=box.MINIMAL)) + + def _handle_exit(self) -> None: + """Handle exit (keyboard interrupt).""" + if self.modified: + if Confirm.ask("\nSave changes before exiting?", default=True): + save_config(self.config, self.config_path) + console.print("[green]Saved.[/green]") + console.print("\nGoodbye!") + + def _bot_settings(self) -> None: + """Bot settings submenu.""" + while True: + self._clear() + console.print("[bold]Bot Settings[/bold]\n") + + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Setting", style="white") + table.add_column("Value", style="green") + + table.add_row("1", "Bot Name", self.config.bot.name) + table.add_row("2", "Owner", self.config.bot.owner or "[dim]not set[/dim]") + table.add_row( + "3", "Respond to DMs", self._status_icon(self.config.bot.respond_to_dms) + ) + table.add_row( + "4", "Filter BBS Protocols", self._status_icon(self.config.bot.filter_bbs_protocols) + ) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + value = Prompt.ask("Bot name", default=self.config.bot.name) + if value != self.config.bot.name: + self.config.bot.name = value + self.modified = True + elif choice == 2: + value = Prompt.ask("Owner", default=self.config.bot.owner) + if value != self.config.bot.owner: + self.config.bot.owner = value + self.modified = True + elif choice == 3: + value = Confirm.ask("Respond to DMs?", default=self.config.bot.respond_to_dms) + if value != self.config.bot.respond_to_dms: + self.config.bot.respond_to_dms = value + self.modified = True + elif choice == 4: + value = Confirm.ask("Filter BBS protocols?", default=self.config.bot.filter_bbs_protocols) + if value != self.config.bot.filter_bbs_protocols: + self.config.bot.filter_bbs_protocols = value + self.modified = True + + def _connection_settings(self) -> None: + """Connection settings submenu.""" + while True: + self._clear() + console.print("[bold]Connection Settings[/bold]\n") + + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Setting", style="white") + table.add_column("Value", style="green") + + table.add_row("1", "Connection Type", self.config.connection.type) + table.add_row("2", "Serial Port", self.config.connection.serial_port) + table.add_row("3", "TCP Host", self.config.connection.tcp_host) + table.add_row("4", "TCP Port", str(self.config.connection.tcp_port)) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + console.print("\n[cyan]1.[/cyan] serial - USB Serial connection") + console.print("[cyan]2.[/cyan] tcp - TCP Network connection") + sel = IntPrompt.ask("Select", default=1 if self.config.connection.type == "serial" else 2) + value = "serial" if sel == 1 else "tcp" + if value != self.config.connection.type: + self.config.connection.type = value + self.modified = True + elif choice == 2: + value = Prompt.ask("Serial port", default=self.config.connection.serial_port) + if value != self.config.connection.serial_port: + self.config.connection.serial_port = value + self.modified = True + elif choice == 3: + value = Prompt.ask("TCP host", default=self.config.connection.tcp_host) + if value != self.config.connection.tcp_host: + self.config.connection.tcp_host = value + self.modified = True + elif choice == 4: + value = IntPrompt.ask("TCP port", default=self.config.connection.tcp_port) + if value != self.config.connection.tcp_port: + self.config.connection.tcp_port = value + self.modified = True + + def _llm_settings(self) -> None: + """LLM backend settings submenu.""" + while True: + self._clear() + console.print("[bold]LLM Backend Settings[/bold]\n") + + # Mask API key for display + api_key_display = "****" + self.config.llm.api_key[-4:] if len(self.config.llm.api_key) > 4 else "[dim]not set[/dim]" + + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Setting", style="white") + table.add_column("Value", style="green") + + table.add_row("1", "Backend", self.config.llm.backend) + table.add_row("2", "API Key", api_key_display) + table.add_row("3", "Base URL", self.config.llm.base_url) + table.add_row("4", "Model", self.config.llm.model) + table.add_row("5", "System Prompt", f"[dim]{len(self.config.llm.system_prompt)} chars[/dim]") + table.add_row("6", "Use System Prompt", self._status_icon(self.config.llm.use_system_prompt)) + table.add_row("7", "Web Search", self._status_icon(self.config.llm.web_search)) + table.add_row("8", "Google Grounding", self._status_icon(self.config.llm.google_grounding)) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + console.print("\n[cyan]1.[/cyan] openai - OpenAI / OpenAI-compatible (LiteLLM, etc)") + console.print("[cyan]2.[/cyan] anthropic - Anthropic Claude") + console.print("[cyan]3.[/cyan] google - Google Gemini") + sel = IntPrompt.ask("Select", default=1) + backends = {1: "openai", 2: "anthropic", 3: "google"} + value = backends.get(sel, "openai") + if value != self.config.llm.backend: + self.config.llm.backend = value + self.modified = True + elif choice == 2: + value = Prompt.ask("API Key", password=True) + if value: + self.config.llm.api_key = value + self.modified = True + elif choice == 3: + value = Prompt.ask("Base URL", default=self.config.llm.base_url) + if value != self.config.llm.base_url: + self.config.llm.base_url = value + self.modified = True + elif choice == 4: + value = Prompt.ask("Model", default=self.config.llm.model) + if value != self.config.llm.model: + self.config.llm.model = value + self.modified = True + elif choice == 5: + console.print("\n[dim]Current prompt:[/dim]") + console.print(self.config.llm.system_prompt or "(empty)") + console.print() + if Confirm.ask("Edit system prompt?", default=False): + console.print("[dim]Enter new prompt, or leave empty to clear[/dim]") + value = Prompt.ask("New system prompt", default="") + if value != self.config.llm.system_prompt: + self.config.llm.system_prompt = value + self.modified = True + elif choice == 6: + self.config.llm.use_system_prompt = not self.config.llm.use_system_prompt + self.modified = True + elif choice == 7: + self.config.llm.web_search = not self.config.llm.web_search + self.modified = True + elif choice == 8: + if self.config.llm.backend == "google": + self.config.llm.google_grounding = not self.config.llm.google_grounding + self.modified = True + else: + console.print("[yellow]Google grounding is only available with the google backend.[/yellow]") + input("Press Enter to continue...") + + def _command_settings(self) -> None: + """Command settings submenu.""" + # All built-in commands + builtin = ["help", "ping", "status", "weather", "reset", "clear"] + + while True: + self._clear() + console.print("[bold]Command Settings[/bold]\n") + + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Command", style="white") + table.add_column("Status", style="green") + + disabled = set(c.lower() for c in self.config.commands.disabled_commands) + for i, cmd in enumerate(builtin, 1): + status = "[red]disabled[/red]" if cmd in disabled else "[green]enabled[/green]" + table.add_row(str(i), f"!{cmd}", status) + + table.add_row("", "", "") + table.add_row("7", "Command Prefix", self.config.commands.prefix) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif 1 <= choice <= len(builtin): + cmd = builtin[choice - 1] + if cmd in disabled: + self.config.commands.disabled_commands.remove(cmd) + console.print(f"[green]!{cmd} enabled[/green]") + else: + self.config.commands.disabled_commands.append(cmd) + console.print(f"[red]!{cmd} disabled[/red]") + self.modified = True + elif choice == 7: + value = Prompt.ask("Command prefix", default=self.config.commands.prefix) + if value != self.config.commands.prefix: + self.config.commands.prefix = value + self.modified = True + + def _context_settings(self) -> None: + """Mesh context settings submenu.""" + while True: + self._clear() + console.print("[bold]Mesh Context Settings[/bold]\n") + console.print("[dim]Passively observes channel traffic to give the LLM situational awareness.[/dim]\n") + + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Setting", style="white") + table.add_column("Value", style="green") + + channels = self.config.context.observe_channels + ch_display = ", ".join(str(c) for c in channels) if channels else "[dim]all[/dim]" + nodes = self.config.context.ignore_nodes + node_display = ", ".join(nodes) if nodes else "[dim]none[/dim]" + age_days = self.config.context.max_age // 86400 + + table.add_row("1", "Enabled", self._status_icon(self.config.context.enabled)) + table.add_row("2", "Observe Channels", ch_display) + table.add_row("3", "Ignore Nodes", node_display) + table.add_row("4", "Max Age", f"{age_days}d") + table.add_row("5", "Max Context Items", str(self.config.context.max_context_items)) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + self.config.context.enabled = not self.config.context.enabled + self.modified = True + elif choice == 2: + console.print("\n[dim]Enter channel indices separated by commas, or leave empty for all.[/dim]") + value = Prompt.ask("Channels", default=", ".join(str(c) for c in channels)) + parsed = [int(x.strip()) for x in value.split(",") if x.strip().isdigit()] if value.strip() else [] + if parsed != self.config.context.observe_channels: + self.config.context.observe_channels = parsed + self.modified = True + elif choice == 3: + console.print("\n[dim]Enter node IDs separated by commas, or leave empty for none.[/dim]") + value = Prompt.ask("Node IDs", default=", ".join(nodes)) + parsed = [x.strip() for x in value.split(",") if x.strip()] if value.strip() else [] + if parsed != self.config.context.ignore_nodes: + self.config.context.ignore_nodes = parsed + self.modified = True + elif choice == 4: + value = IntPrompt.ask("Max age (days)", default=age_days) + seconds = value * 86400 + if seconds != self.config.context.max_age: + self.config.context.max_age = seconds + self.modified = True + elif choice == 5: + value = IntPrompt.ask("Max context items", default=self.config.context.max_context_items) + if value != self.config.context.max_context_items: + self.config.context.max_context_items = value + self.modified = True + + def _weather_settings(self) -> None: + """Weather settings submenu.""" + while True: + self._clear() + console.print("[bold]Weather Settings[/bold]\n") + + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Setting", style="white") + table.add_column("Value", style="green") + + table.add_row("1", "Primary Provider", self.config.weather.primary) + table.add_row("2", "Fallback Provider", self.config.weather.fallback) + table.add_row("3", "Default Location", self.config.weather.default_location or "[dim]not set[/dim]") + table.add_row("4", "Open-Meteo URL", self.config.weather.openmeteo.url) + table.add_row("5", "wttr.in URL", self.config.weather.wttr.url) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + console.print("\n[cyan]1.[/cyan] openmeteo - Open-Meteo API (free, no key)") + console.print("[cyan]2.[/cyan] wttr - wttr.in (free, simple)") + console.print("[cyan]3.[/cyan] llm - Use LLM with web search") + sel = IntPrompt.ask("Select", default=1) + providers = {1: "openmeteo", 2: "wttr", 3: "llm"} + value = providers.get(sel, "openmeteo") + if value != self.config.weather.primary: + self.config.weather.primary = value + self.modified = True + elif choice == 2: + console.print("\n[cyan]1.[/cyan] openmeteo") + console.print("[cyan]2.[/cyan] wttr") + console.print("[cyan]3.[/cyan] llm") + console.print("[cyan]4.[/cyan] none - No fallback") + sel = IntPrompt.ask("Select", default=3) + providers = {1: "openmeteo", 2: "wttr", 3: "llm", 4: "none"} + value = providers.get(sel, "llm") + if value != self.config.weather.fallback: + self.config.weather.fallback = value + self.modified = True + elif choice == 3: + value = Prompt.ask("Default location", default=self.config.weather.default_location) + if value != self.config.weather.default_location: + self.config.weather.default_location = value + self.modified = True + elif choice == 4: + value = Prompt.ask("Open-Meteo URL", default=self.config.weather.openmeteo.url) + if value != self.config.weather.openmeteo.url: + self.config.weather.openmeteo.url = value + self.modified = True + elif choice == 5: + value = Prompt.ask("wttr.in URL", default=self.config.weather.wttr.url) + if value != self.config.weather.wttr.url: + self.config.weather.wttr.url = value + self.modified = True + + def _response_settings(self) -> None: + """Response settings submenu.""" + while True: + self._clear() + console.print("[bold]Response Settings[/bold]\n") + + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Setting", style="white") + table.add_column("Value", style="green") + + table.add_row("1", "Min Delay (seconds)", str(self.config.response.delay_min)) + table.add_row("2", "Max Delay (seconds)", str(self.config.response.delay_max)) + table.add_row("3", "Max Length (chars)", str(self.config.response.max_length)) + table.add_row("4", "Max Messages", str(self.config.response.max_messages)) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + value = float(Prompt.ask("Min delay", default=str(self.config.response.delay_min))) + if value != self.config.response.delay_min: + self.config.response.delay_min = value + self.modified = True + elif choice == 2: + value = float(Prompt.ask("Max delay", default=str(self.config.response.delay_max))) + if value != self.config.response.delay_max: + self.config.response.delay_max = value + self.modified = True + elif choice == 3: + value = IntPrompt.ask("Max length", default=self.config.response.max_length) + if value != self.config.response.max_length: + self.config.response.max_length = value + self.modified = True + elif choice == 4: + value = IntPrompt.ask("Max messages", default=self.config.response.max_messages) + if value != self.config.response.max_messages: + self.config.response.max_messages = value + self.modified = True + + def _history_settings(self) -> None: + """History settings submenu.""" + while True: + self._clear() + console.print("[bold]History & Memory Settings[/bold]\n") + + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Setting", style="white") + table.add_column("Value", style="green") + + timeout_hours = self.config.history.conversation_timeout // 3600 + table.add_row("1", "Database File", self.config.history.database) + table.add_row("2", "Max Messages Per User", str(self.config.history.max_messages_per_user)) + table.add_row("3", "Conversation Timeout", f"{timeout_hours}h") + table.add_row("4", "Auto Cleanup", self._status_icon(self.config.history.auto_cleanup)) + table.add_row("5", "Max Age (days)", str(self.config.history.max_age_days)) + table.add_row("", "[bold]Memory[/bold]", "") + table.add_row("6", "Memory Enabled", self._status_icon(self.config.memory.enabled)) + table.add_row("7", "Window Size", str(self.config.memory.window_size)) + table.add_row("8", "Summarize Threshold", str(self.config.memory.summarize_threshold)) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + value = Prompt.ask("Database file", default=self.config.history.database) + if value != self.config.history.database: + self.config.history.database = value + self.modified = True + elif choice == 2: + value = IntPrompt.ask( + "Max messages per user", default=self.config.history.max_messages_per_user + ) + if value != self.config.history.max_messages_per_user: + self.config.history.max_messages_per_user = value + self.modified = True + elif choice == 3: + value = IntPrompt.ask("Timeout (hours)", default=timeout_hours) + seconds = value * 3600 + if seconds != self.config.history.conversation_timeout: + self.config.history.conversation_timeout = seconds + self.modified = True + elif choice == 4: + value = Confirm.ask("Enable auto cleanup?", default=self.config.history.auto_cleanup) + if value != self.config.history.auto_cleanup: + self.config.history.auto_cleanup = value + self.modified = True + elif choice == 5: + value = IntPrompt.ask("Max age (days)", default=self.config.history.max_age_days) + if value != self.config.history.max_age_days: + self.config.history.max_age_days = value + self.modified = True + elif choice == 6: + value = Confirm.ask("Enable memory?", default=self.config.memory.enabled) + if value != self.config.memory.enabled: + self.config.memory.enabled = value + self.modified = True + elif choice == 7: + value = IntPrompt.ask("Window size", default=self.config.memory.window_size) + if value != self.config.memory.window_size: + self.config.memory.window_size = value + self.modified = True + elif choice == 8: + value = IntPrompt.ask("Summarize threshold", default=self.config.memory.summarize_threshold) + if value != self.config.memory.summarize_threshold: + self.config.memory.summarize_threshold = value + self.modified = True + + def _meshmonitor_settings(self) -> None: + """MeshMonitor sync settings submenu.""" + while True: + self._clear() + console.print("[bold]MeshMonitor Sync Settings[/bold]\n") + console.print("[dim]Sync auto-responder triggers from MeshMonitor to avoid duplicate responses.[/dim]\n") + + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Setting", style="white") + table.add_column("Value", style="green") + + table.add_row("1", "Enabled", self._status_icon(self.config.meshmonitor.enabled)) + table.add_row("2", "MeshMonitor URL", self.config.meshmonitor.url or "[dim]not set[/dim]") + table.add_row("3", "Inject into Prompt", self._status_icon(self.config.meshmonitor.inject_into_prompt)) + table.add_row("4", "Refresh Interval", f"{self.config.meshmonitor.refresh_interval}s") + table.add_row("5", "View Triggers", "[dim]Fetch and display[/dim]") + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + self.config.meshmonitor.enabled = not self.config.meshmonitor.enabled + self.modified = True + elif choice == 2: + value = Prompt.ask("MeshMonitor URL (e.g., http://100.64.0.11:3333)", + default=self.config.meshmonitor.url) + if value != self.config.meshmonitor.url: + self.config.meshmonitor.url = value + self.modified = True + elif choice == 3: + self.config.meshmonitor.inject_into_prompt = not self.config.meshmonitor.inject_into_prompt + self.modified = True + elif choice == 4: + value = IntPrompt.ask("Refresh interval (seconds)", default=self.config.meshmonitor.refresh_interval) + if value != self.config.meshmonitor.refresh_interval: + self.config.meshmonitor.refresh_interval = value + self.modified = True + elif choice == 5: + self._view_meshmonitor_triggers() + + def _view_meshmonitor_triggers(self) -> None: + """Fetch and display MeshMonitor triggers.""" + self._clear() + console.print("[bold]MeshMonitor Triggers[/bold]\n") + + if not self.config.meshmonitor.url: + console.print("[yellow]MeshMonitor URL not configured.[/yellow]") + input("\nPress Enter to continue...") + return + + console.print(f"[dim]Fetching from {self.config.meshmonitor.url}...[/dim]\n") + + try: + from ..meshmonitor import MeshMonitorSync + sync = MeshMonitorSync(self.config.meshmonitor.url) + count = sync.load() + + if count == 0: + if sync.last_error: + console.print(f"[red]Error: {sync.last_error}[/red]") + else: + console.print("[yellow]No triggers configured in MeshMonitor.[/yellow]") + else: + console.print(f"[green]Loaded {count} triggers:[/green]\n") + for trigger in sync.raw_triggers: + console.print(f" [cyan]{trigger}[/cyan]") + except Exception as e: + console.print(f"[red]Failed to fetch triggers: {e}[/red]") + + input("\nPress Enter to continue...") + + + def _knowledge_settings(self) -> None: + """Knowledge base settings submenu.""" + while True: + self._clear() + console.print("[bold]Knowledge Base Settings[/bold]\n") + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Setting", style="white") + table.add_column("Value", style="green") + + table.add_row("1", "Enabled", self._status_icon(self.config.knowledge.enabled)) + table.add_row("2", "Database Path", self.config.knowledge.db_path or "[dim]not set[/dim]") + table.add_row("3", "Results Count", str(self.config.knowledge.top_k)) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + value = Confirm.ask("Enable knowledge base?", default=self.config.knowledge.enabled) + if value != self.config.knowledge.enabled: + self.config.knowledge.enabled = value + self.modified = True + elif choice == 2: + value = Prompt.ask("Database path", default=self.config.knowledge.db_path) + if value != self.config.knowledge.db_path: + self.config.knowledge.db_path = value + self.modified = True + elif choice == 3: + value = IntPrompt.ask("Results count (top_k)", default=self.config.knowledge.top_k) + if value != self.config.knowledge.top_k: + self.config.knowledge.top_k = value + self.modified = True + + def _mesh_sources_settings(self) -> None: + """Mesh data sources settings submenu.""" + while True: + self._clear() + console.print("[bold]Mesh Data Sources[/bold]\n") + console.print("[dim]Connect to Meshview and/or MeshMonitor instances for live mesh data.[/dim]\n") + + # Display configured sources + if self.config.mesh_sources: + table = Table(box=box.ROUNDED) + table.add_column("#", style="cyan", width=3) + table.add_column("Name", style="white") + table.add_column("Type", style="blue") + table.add_column("URL", style="dim") + table.add_column("Enabled", style="green") + + for i, src in enumerate(self.config.mesh_sources, 1): + table.add_row( + str(i), + src.name, + src.type, + src.url[:40] + "..." if len(src.url) > 40 else src.url, + self._status_icon(src.enabled), + ) + console.print(table) + else: + console.print("[dim]No sources configured.[/dim]") + + console.print() + console.print("[cyan]1.[/cyan] Add source") + console.print("[cyan]2.[/cyan] Edit source") + console.print("[cyan]3.[/cyan] Remove source") + console.print("[cyan]4.[/cyan] Test source") + console.print("[cyan]0.[/cyan] Back") + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + self._add_mesh_source() + elif choice == 2: + self._edit_mesh_source() + elif choice == 3: + self._remove_mesh_source() + elif choice == 4: + self._test_mesh_source() + + def _add_mesh_source(self) -> None: + """Add a new mesh data source.""" + self._clear() + console.print("[bold]Add Mesh Source[/bold]\n") + + # Get name + existing_names = {s.name for s in self.config.mesh_sources} + while True: + name = Prompt.ask("Source name (unique identifier)") + if not name: + console.print("[yellow]Name is required.[/yellow]") + continue + if name in existing_names: + console.print(f"[yellow]Name '{name}' already exists. Choose another.[/yellow]") + continue + break + + # Get type + console.print("\n[cyan]1.[/cyan] meshview - Meshview instance") + console.print("[cyan]2.[/cyan] meshmonitor - MeshMonitor instance") + type_choice = IntPrompt.ask("Source type", default=1) + source_type = "meshview" if type_choice == 1 else "meshmonitor" + + # Get URL + url = Prompt.ask("URL (e.g., https://meshview.example.com or http://192.168.1.100:3333)") + + # Get API token (MeshMonitor only) + api_token = "" + if source_type == "meshmonitor": + console.print("\n[dim]API token is required for MeshMonitor. Use ${ENV_VAR} for env vars.[/dim]") + api_token = Prompt.ask("API token", default="") + + # Get refresh interval + refresh_interval = IntPrompt.ask("Refresh interval (seconds)", default=300) + + # Create and add source + source = MeshSourceConfig( + name=name, + type=source_type, + url=url, + api_token=api_token, + refresh_interval=refresh_interval, + enabled=True, + ) + self.config.mesh_sources.append(source) + self.modified = True + + console.print(f"\n[green]Source '{name}' added.[/green]") + input("Press Enter to continue...") + + def _edit_mesh_source(self) -> None: + """Edit an existing mesh data source.""" + if not self.config.mesh_sources: + console.print("[yellow]No sources to edit.[/yellow]") + input("\nPress Enter to continue...") + return + + self._clear() + console.print("[bold]Edit Mesh Source[/bold]\n") + + # Show list + for i, src in enumerate(self.config.mesh_sources, 1): + status = "[green]enabled[/green]" if src.enabled else "[red]disabled[/red]" + console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type}) - {status}") + + console.print("[cyan]0.[/cyan] Cancel") + console.print() + + choice = IntPrompt.ask("Select source to edit", default=0) + if choice == 0 or choice > len(self.config.mesh_sources): + return + + src = self.config.mesh_sources[choice - 1] + + while True: + self._clear() + console.print(f"[bold]Edit Source: {src.name}[/bold]\n") + + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Setting", style="white") + table.add_column("Value", style="green") + + table.add_row("1", "Name", src.name) + table.add_row("2", "Type", src.type) + table.add_row("3", "URL", src.url) + if src.type == "meshmonitor": + token_display = "****" + src.api_token[-4:] if len(src.api_token) > 4 else src.api_token or "[dim]not set[/dim]" + table.add_row("4", "API Token", token_display) + table.add_row("5", "Refresh Interval", f"{src.refresh_interval}s") + table.add_row("6", "Enabled", self._status_icon(src.enabled)) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + opt = IntPrompt.ask("Select option", default=0) + + if opt == 0: + return + elif opt == 1: + existing_names = {s.name for s in self.config.mesh_sources if s != src} + value = Prompt.ask("Name", default=src.name) + if value and value not in existing_names: + src.name = value + self.modified = True + elif value in existing_names: + console.print("[yellow]Name already exists.[/yellow]") + elif opt == 2: + console.print("\n[cyan]1.[/cyan] meshview") + console.print("[cyan]2.[/cyan] meshmonitor") + t = IntPrompt.ask("Type", default=1 if src.type == "meshview" else 2) + new_type = "meshview" if t == 1 else "meshmonitor" + if new_type != src.type: + src.type = new_type + self.modified = True + elif opt == 3: + value = Prompt.ask("URL", default=src.url) + if value != src.url: + src.url = value + self.modified = True + elif opt == 4 and src.type == "meshmonitor": + value = Prompt.ask("API Token", default=src.api_token) + if value != src.api_token: + src.api_token = value + self.modified = True + elif opt == 5: + value = IntPrompt.ask("Refresh interval (seconds)", default=src.refresh_interval) + if value != src.refresh_interval: + src.refresh_interval = value + self.modified = True + elif opt == 6: + src.enabled = not src.enabled + self.modified = True + + def _remove_mesh_source(self) -> None: + """Remove a mesh data source.""" + if not self.config.mesh_sources: + console.print("[yellow]No sources to remove.[/yellow]") + input("\nPress Enter to continue...") + return + + self._clear() + console.print("[bold]Remove Mesh Source[/bold]\n") + + # Show list + for i, src in enumerate(self.config.mesh_sources, 1): + console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type})") + + console.print("[cyan]0.[/cyan] Cancel") + console.print() + + choice = IntPrompt.ask("Select source to remove", default=0) + if choice == 0 or choice > len(self.config.mesh_sources): + return + + src = self.config.mesh_sources[choice - 1] + if Confirm.ask(f"Remove source '{src.name}'?", default=False): + self.config.mesh_sources.pop(choice - 1) + self.modified = True + console.print(f"[green]Source '{src.name}' removed.[/green]") + input("Press Enter to continue...") + + def _test_mesh_source(self) -> None: + """Test a mesh data source connection.""" + if not self.config.mesh_sources: + console.print("[yellow]No sources to test.[/yellow]") + input("\nPress Enter to continue...") + return + + self._clear() + console.print("[bold]Test Mesh Source[/bold]\n") + + # Show list + for i, src in enumerate(self.config.mesh_sources, 1): + console.print(f"[cyan]{i}.[/cyan] {src.name} ({src.type})") + + console.print("[cyan]0.[/cyan] Cancel") + console.print() + + choice = IntPrompt.ask("Select source to test", default=0) + if choice == 0 or choice > len(self.config.mesh_sources): + return + + src = self.config.mesh_sources[choice - 1] + console.print(f"\n[dim]Testing {src.name} ({src.url})...[/dim]\n") + + try: + if src.type == "meshview": + from ..sources.meshview import MeshviewSource + source = MeshviewSource(url=src.url, refresh_interval=src.refresh_interval) + else: + from ..sources.meshmonitor_data import MeshMonitorDataSource + source = MeshMonitorDataSource( + url=src.url, + api_token=src.api_token, + refresh_interval=src.refresh_interval, + ) + + success = source.fetch_all() + + if success: + console.print("[green]Connection successful![/green]\n") + console.print(f" Nodes: {len(source.nodes)}") + if src.type == "meshview": + console.print(f" Edges: {len(source.edges)}") + console.print(f" Stats: {'loaded' if source.stats else 'none'}") + console.print(f" Counts: {'loaded' if source.counts else 'none'}") + else: + console.print(f" Channels: {len(source.channels)}") + console.print(f" Telemetry: {len(source.telemetry)}") + console.print(f" Traceroutes: {len(source.traceroutes)}") + console.print(f" Packets: {len(source.packets)}") + else: + console.print(f"[red]Connection failed: {source.last_error}[/red]") + + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + + input("\nPress Enter to continue...") + + def _mesh_intelligence_settings(self) -> None: + """Mesh intelligence settings submenu.""" + while True: + self._clear() + console.print("[bold]Mesh Intelligence Settings[/bold]\n") + console.print("[dim]Region-based health scoring for mesh analysis.[/dim]\n") + + mi = self.config.mesh_intelligence + + table = Table(box=box.ROUNDED) + table.add_column("Option", style="cyan", width=4) + table.add_column("Setting", style="white") + table.add_column("Value", style="green") + + table.add_row("1", "Enabled", self._status_icon(mi.enabled)) + table.add_row("2", "Regions", f"{len(mi.regions)} defined" if mi.regions else "[dim]none[/dim]") + table.add_row("3", "Locality Radius (miles)", str(mi.locality_radius_miles)) + table.add_row("4", "Offline Threshold (hours)", str(mi.offline_threshold_hours)) + table.add_row("5", "Packet Threshold (24h)", str(mi.packet_threshold)) + table.add_row("6", "Battery Warning (%)", str(mi.battery_warning_percent)) + crit_nodes = getattr(mi, 'critical_nodes', []) + alert_ch = getattr(mi, 'alert_channel', -1) + alert_cd = getattr(mi, 'alert_cooldown_minutes', 30) + table.add_row("7", "Critical Nodes", ", ".join(crit_nodes) if crit_nodes else "[dim]none[/dim]") + table.add_row("8", "Alert Channel", f"Channel {alert_ch}" if alert_ch >= 0 else "[dim]disabled[/dim]") + table.add_row("9", "Alert Cooldown (min)", str(alert_cd)) + table.add_row("0", "Back", "") + + console.print(table) + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + mi.enabled = not mi.enabled + self.modified = True + elif choice == 2: + self._edit_regions() + elif choice == 3: + value = float(Prompt.ask("Locality radius (miles)", default=str(mi.locality_radius_miles))) + if value != mi.locality_radius_miles: + mi.locality_radius_miles = value + self.modified = True + elif choice == 4: + value = IntPrompt.ask("Offline threshold (hours)", default=mi.offline_threshold_hours) + if value != mi.offline_threshold_hours: + mi.offline_threshold_hours = value + self.modified = True + elif choice == 5: + value = IntPrompt.ask("Packet threshold (24h)", default=mi.packet_threshold) + if value != mi.packet_threshold: + mi.packet_threshold = value + self.modified = True + elif choice == 6: + value = IntPrompt.ask("Battery warning (%)", default=mi.battery_warning_percent) + if value != mi.battery_warning_percent: + mi.battery_warning_percent = value + self.modified = True + elif choice == 7: + self._edit_critical_nodes() + elif choice == 8: + ch = Prompt.ask("Alert channel (-1 to disable)", default=str(getattr(mi, 'alert_channel', -1))) + try: + new_ch = int(ch) + if new_ch != getattr(mi, 'alert_channel', -1): + mi.alert_channel = new_ch + self.modified = True + except ValueError: + pass + elif choice == 9: + mins = Prompt.ask("Alert cooldown (minutes)", default=str(getattr(mi, 'alert_cooldown_minutes', 30))) + try: + new_mins = int(mins) + if new_mins != getattr(mi, 'alert_cooldown_minutes', 30): + mi.alert_cooldown_minutes = new_mins + self.modified = True + except ValueError: + pass + + def _edit_critical_nodes(self) -> None: + """Edit critical node list.""" + mi = self.config.mesh_intelligence + + while True: + self._clear() + console.print("[bold]Critical Nodes[/bold]") + console.print("[dim]These nodes trigger priority alerts when they go offline.[/dim]") + + crit_nodes = getattr(mi, 'critical_nodes', None) + if crit_nodes is None: + mi.critical_nodes = [] + crit_nodes = mi.critical_nodes + + if crit_nodes: + for i, name in enumerate(crit_nodes, 1): + console.print(f" {i}. {name}") + else: + console.print(" [dim]No critical nodes configured[/dim]") + + console.print("[cyan]A[/cyan] Add [cyan]R[/cyan] Remove [cyan]B[/cyan] Back") + choice = Prompt.ask("Choice", default="b").strip().lower() + + if choice == "b" or choice == "": + break + elif choice == "a": + name = Prompt.ask("Node short name (e.g., MHR)") + if name and name.strip(): + mi.critical_nodes.append(name.strip()) + self.modified = True + elif choice == "r": + if crit_nodes: + idx = Prompt.ask("Number to remove") + try: + idx = int(idx) + if 1 <= idx <= len(crit_nodes): + removed = mi.critical_nodes.pop(idx - 1) + console.print(f"Removed: {removed}") + self.modified = True + except (ValueError, IndexError): + pass + + def _edit_regions(self) -> None: + """Edit region anchor points.""" + from ..config import RegionAnchor + + while True: + self._clear() + console.print("[bold]Region Anchors[/bold]\n") + console.print("[dim]Define region center points. Nodes are assigned to nearest region.[/dim]\n") + + regions = self.config.mesh_intelligence.regions + + if regions: + table = Table(box=box.ROUNDED) + table.add_column("#", style="cyan", width=3) + table.add_column("Name", style="white") + table.add_column("Latitude", style="green") + table.add_column("Longitude", style="green") + + for i, r in enumerate(regions, 1): + table.add_row(str(i), r.name, f"{r.lat:.4f}", f"{r.lon:.4f}") + console.print(table) + else: + console.print("[dim]No regions defined.[/dim]") + + console.print() + console.print("[cyan]1.[/cyan] Add region") + console.print("[cyan]2.[/cyan] Edit region") + console.print("[cyan]3.[/cyan] Remove region") + console.print("[cyan]4.[/cyan] Load ID/UT defaults") + console.print("[cyan]0.[/cyan] Back") + console.print() + + choice = IntPrompt.ask("Select option", default=0) + + if choice == 0: + return + elif choice == 1: + name = Prompt.ask("Region name") + if name: + lat = float(Prompt.ask("Center latitude", default="0.0")) + lon = float(Prompt.ask("Center longitude", default="0.0")) + self.config.mesh_intelligence.regions.append( + RegionAnchor(name=name, lat=lat, lon=lon) + ) + self.modified = True + console.print(f"[green]Added: {name} @ {lat}, {lon}[/green]") + input("Press Enter to continue...") + elif choice == 2: + if not regions: + console.print("[yellow]No regions to edit.[/yellow]") + input("\nPress Enter to continue...") + continue + console.print() + for i, r in enumerate(regions, 1): + console.print(f"[cyan]{i}.[/cyan] {r.name}") + console.print("[cyan]0.[/cyan] Cancel") + idx = IntPrompt.ask("Select region", default=0) + if 1 <= idx <= len(regions): + r = regions[idx - 1] + r.name = Prompt.ask("Name", default=r.name) + r.lat = float(Prompt.ask("Latitude", default=str(r.lat))) + r.lon = float(Prompt.ask("Longitude", default=str(r.lon))) + self.modified = True + elif choice == 3: + if not regions: + console.print("[yellow]No regions to remove.[/yellow]") + input("\nPress Enter to continue...") + continue + console.print() + for i, r in enumerate(regions, 1): + console.print(f"[cyan]{i}.[/cyan] {r.name}") + console.print("[cyan]0.[/cyan] Cancel") + idx = IntPrompt.ask("Select region to remove", default=0) + if 1 <= idx <= len(regions): + removed = self.config.mesh_intelligence.regions.pop(idx - 1) + self.modified = True + console.print(f"[green]Removed: {removed.name}[/green]") + input("Press Enter to continue...") + elif choice == 4: + # Load ID/UT region defaults + self.config.mesh_intelligence.regions = [ + # Idaho + RegionAnchor(name="Northern ID", lat=47.6777, lon=-116.7805), # Coeur d'Alene + RegionAnchor(name="Central ID", lat=45.1757, lon=-113.8958), # Salmon + RegionAnchor(name="South Western ID", lat=43.6150, lon=-116.2023), # Boise + RegionAnchor(name="South Central ID", lat=42.5558, lon=-114.4701), # Twin Falls + RegionAnchor(name="South Eastern ID", lat=43.4926, lon=-112.0341), # Idaho Falls + # Utah + RegionAnchor(name="Northern UT", lat=41.2230, lon=-111.9738), # Ogden + RegionAnchor(name="Central UT", lat=40.7608, lon=-111.8910), # Salt Lake City + RegionAnchor(name="Eastern UT", lat=40.4555, lon=-109.5287), # Vernal + RegionAnchor(name="Western UT", lat=40.5308, lon=-112.2983), # Tooele + RegionAnchor(name="Southern UT", lat=37.0965, lon=-113.5684), # St. George + ] + self.modified = True + console.print("[green]Loaded 10 ID/UT region defaults.[/green]") + input("Press Enter to continue...") + + def _setup_wizard(self) -> None: + """First-time setup wizard.""" + self._clear() + console.print(Panel("[bold]MeshAI Setup Wizard[/bold]", style="cyan")) + console.print("\nThis wizard will help you configure MeshAI.\n") + + # Step 1: Bot identity + console.print("[bold cyan]Step 1: Bot Identity[/bold cyan]") + self.config.bot.name = Prompt.ask("Bot name", default="ai") + self.config.bot.owner = Prompt.ask("Your name/callsign", default="") + console.print() + + # Step 2: Connection + console.print("[bold cyan]Step 2: Meshtastic Connection[/bold cyan]") + console.print("[cyan]1.[/cyan] serial - USB Serial") + console.print("[cyan]2.[/cyan] tcp - Network TCP") + sel = IntPrompt.ask("Connection type", default=1) + self.config.connection.type = "serial" if sel == 1 else "tcp" + + if self.config.connection.type == "serial": + self.config.connection.serial_port = Prompt.ask( + "Serial port", default="/dev/ttyUSB0" + ) + else: + self.config.connection.tcp_host = Prompt.ask( + "TCP host", default="192.168.1.100" + ) + self.config.connection.tcp_port = IntPrompt.ask("TCP port", default=4403) + console.print() + + # Step 3: LLM + console.print("[bold cyan]Step 3: LLM Backend[/bold cyan]") + console.print("[cyan]1.[/cyan] openai - OpenAI / OpenAI-compatible") + console.print("[cyan]2.[/cyan] anthropic - Anthropic Claude") + console.print("[cyan]3.[/cyan] google - Google Gemini") + sel = IntPrompt.ask("Backend", default=1) + backends = {1: "openai", 2: "anthropic", 3: "google"} + self.config.llm.backend = backends.get(sel, "openai") + + self.config.llm.api_key = Prompt.ask("API Key", password=True) + + if self.config.llm.backend == "openai": + if Confirm.ask("Using local/self-hosted API?", default=False): + self.config.llm.base_url = Prompt.ask( + "Base URL", default="http://localhost:4000/v1" + ) + + self.config.llm.model = Prompt.ask("Model", default="gpt-4o-mini") + console.print() + + # Step 4: Weather (optional) + console.print("[bold cyan]Step 4: Weather (optional)[/bold cyan]") + self.config.weather.default_location = Prompt.ask( + "Default location (for !weather)", default="" + ) + console.print() + + self.modified = True + console.print("[green]Setup complete![/green]") + console.print("Press Enter to return to main menu...") + input() + + def _save_only(self) -> None: + """Save config and stay in menu.""" + save_config(self.config, self.config_path) + console.print(f"[green]Configuration saved to {self.config_path}[/green]") + self.modified = False + input("Press Enter to continue...") + + def _save_and_restart(self) -> None: + """Save config and signal bot to restart, stay in menu.""" + self._clear() + console.print("[cyan]Saving configuration...[/cyan]") + save_config(self.config, self.config_path) + console.print("[green]Configuration saved![/green]") + self.modified = False + console.print() + + # Write restart signal file (docker-entrypoint watches for this) + restart_file = Path("/tmp/meshai_restart") + try: + restart_file.touch() + console.print("[cyan]Bot restart signal sent.[/cyan]") + console.print() + console.print("The bot will restart momentarily to apply changes.") + except Exception as e: + console.print(f"[yellow]Could not signal restart: {e}[/yellow]") + + input("\nPress Enter to continue...") + + def _save_restart_exit(self) -> None: + """Save config, signal bot restart, and exit config tool.""" + console.print("[cyan]Saving configuration...[/cyan]") + save_config(self.config, self.config_path) + console.print("[green]Configuration saved![/green]") + self.modified = False + + # Write restart signal file + restart_file = Path("/tmp/meshai_restart") + try: + restart_file.touch() + console.print("[cyan]Bot restart signal sent.[/cyan]") + except Exception as e: + console.print(f"[yellow]Could not signal restart: {e}[/yellow]") + + console.print("\nGoodbye!") + + +def run_configurator(config_path: Optional[Path] = None) -> None: + """Entry point for configurator.""" + configurator = Configurator(config_path) + configurator.run() diff --git a/meshai/config.py b/meshai/config.py index d21291c..1c1921e 100644 --- a/meshai/config.py +++ b/meshai/config.py @@ -198,6 +198,11 @@ class MeshIntelligenceConfig: packet_threshold: int = 500 # Non-text packets per 24h to flag battery_warning_percent: int = 20 # Battery level for warnings + # Alert settings + critical_nodes: list[str] = field(default_factory=list) # Short names of critical nodes (e.g., ["MHR", "HPR"]) + alert_channel: int = -1 # Channel to broadcast alerts on. -1 = disabled, 0+ = channel index + alert_cooldown_minutes: int = 30 # Min minutes between repeated alerts for same condition + @dataclass class Config: diff --git a/meshai/main.py b/meshai/main.py index 2c13e20..a6a279e 100644 --- a/meshai/main.py +++ b/meshai/main.py @@ -43,6 +43,7 @@ class MeshAI: self.health_engine = None self.mesh_reporter = None self.subscription_manager = None + self.alert_engine = None self._last_sub_check: float = 0.0 self.router: Optional[MessageRouter] = None self.responder: Optional[Responder] = None @@ -93,6 +94,12 @@ class MeshAI: self.health_engine.compute(self.data_store) self._last_health_compute = time.time() + # Check for alertable conditions + if self.alert_engine: + alerts = self.alert_engine.check() + if alerts: + await self._dispatch_alerts(alerts) + # Check scheduled subscriptions (every 60 seconds) if self.subscription_manager and self.mesh_reporter: if time.time() - self._last_sub_check >= 60: @@ -264,6 +271,19 @@ class MeshAI: else: self.subscription_manager = None + # Alert engine (needs health engine, reporter, and subscription manager) + if self.health_engine and self.mesh_reporter and self.subscription_manager: + from .alert_engine import AlertEngine + mi = self.config.mesh_intelligence + self.alert_engine = AlertEngine( + health_engine=self.health_engine, + reporter=self.mesh_reporter, + subscription_manager=self.subscription_manager, + critical_nodes=getattr(mi, 'critical_nodes', []), + alert_cooldown_minutes=getattr(mi, 'alert_cooldown_minutes', 30), + ) + logger.info(f"Alert engine initialized (critical nodes: {getattr(mi, 'critical_nodes', [])})") + # Knowledge base (optional - gracefully degrade if deps missing) kb_cfg = self.config.knowledge if kb_cfg.enabled and kb_cfg.db_path: @@ -420,6 +440,40 @@ class MeshAI: if pid_file.exists(): pid_file.unlink() + async def _dispatch_alerts(self, alerts: list[dict]) -> None: + """Dispatch alerts to subscribers and alert channel.""" + mi = self.config.mesh_intelligence + alert_channel = getattr(mi, 'alert_channel', -1) + + for alert in alerts: + message = alert["message"] + logger.info(f"ALERT: {message}") + + # Send to alert channel if configured + if alert_channel >= 0 and self.connector: + try: + self.connector.send_message( + text=message, + destination=None, # Broadcast + channel=alert_channel, + ) + logger.info(f"Alert sent to channel {alert_channel}") + except Exception as e: + logger.error(f"Failed to send channel alert: {e}") + + # Send DMs to matching subscribers + if self.alert_engine and self.subscription_manager: + subscribers = self.alert_engine.get_subscribers_for_alert(alert) + for sub in subscribers: + user_id = sub["user_id"] + try: + await self._send_sub_dm(user_id, message) + logger.info(f"Alert DM sent to {user_id}: {alert['type']}") + except Exception as e: + logger.error(f"Failed to send alert DM to {user_id}: {e}") + + self.alert_engine.clear_pending() + async def _check_scheduled_subs(self) -> None: """Check for and deliver due scheduled reports.""" from datetime import datetime