mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
feat: Phase 1 — multi-source data aggregation from Meshview and MeshMonitor APIs
- Add MeshviewSource class for fetching nodes, edges, stats from Meshview API - Add MeshMonitorDataSource class for fetching nodes, channels, telemetry, traceroutes, network stats, topology, packets, solar from MeshMonitor API - Add MeshSourceManager for managing multiple sources with aggregation - Add MeshSourceConfig dataclass and mesh_sources list to config - Integrate source_manager into main.py with periodic refresh - Add source_manager parameter to MessageRouter (for future Phase 3) - Add Mesh Sources TUI menu with add/edit/remove/test functionality - Update config.example.yaml with mesh_sources section Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
584d1b199d
commit
b945558ba3
9 changed files with 2830 additions and 1856 deletions
|
|
@ -71,7 +71,7 @@ weather:
|
||||||
# === MESHMONITOR INTEGRATION ===
|
# === MESHMONITOR INTEGRATION ===
|
||||||
meshmonitor:
|
meshmonitor:
|
||||||
enabled: false # Enable MeshMonitor trigger sync
|
enabled: false # Enable MeshMonitor trigger sync
|
||||||
url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:8080)
|
url: "" # MeshMonitor web UI URL (e.g. http://192.168.1.100:3333)
|
||||||
inject_into_prompt: true # Include trigger list in LLM prompt
|
inject_into_prompt: true # Include trigger list in LLM prompt
|
||||||
refresh_interval: 300 # Seconds between trigger refreshes
|
refresh_interval: 300 # Seconds between trigger refreshes
|
||||||
|
|
||||||
|
|
@ -80,5 +80,23 @@ knowledge:
|
||||||
enabled: false # Enable knowledge base search
|
enabled: false # Enable knowledge base search
|
||||||
db_path: "" # Path to knowledge SQLite database
|
db_path: "" # Path to knowledge SQLite database
|
||||||
top_k: 5 # Number of chunks to retrieve per query
|
top_k: 5 # Number of chunks to retrieve per query
|
||||||
fts_weight: 0.5 # Weight for FTS5 keyword matches (0-1)
|
|
||||||
vector_weight: 0.5 # Weight for vector semantic matches (0-1)
|
# === MESH DATA SOURCES ===
|
||||||
|
# Connect to Meshview and/or MeshMonitor instances for live mesh
|
||||||
|
# network analysis. Supports multiple sources. Configure via TUI
|
||||||
|
# with meshai --config (Mesh Sources menu).
|
||||||
|
#
|
||||||
|
# mesh_sources:
|
||||||
|
# - name: "my-meshview"
|
||||||
|
# type: meshview
|
||||||
|
# url: "https://meshview.example.com"
|
||||||
|
# refresh_interval: 300
|
||||||
|
# enabled: true
|
||||||
|
#
|
||||||
|
# - name: "my-meshmonitor"
|
||||||
|
# type: meshmonitor
|
||||||
|
# url: "http://192.168.1.100:3333"
|
||||||
|
# api_token: "${MM_API_TOKEN}"
|
||||||
|
# refresh_interval: 300
|
||||||
|
# enabled: true
|
||||||
|
mesh_sources: []
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
605
meshai/config.py
605
meshai/config.py
|
|
@ -1,290 +1,315 @@
|
||||||
"""Configuration management for MeshAI."""
|
"""Configuration management for MeshAI."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
_config_logger = logging.getLogger(__name__)
|
_config_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BotConfig:
|
class BotConfig:
|
||||||
"""Bot identity and trigger settings."""
|
"""Bot identity and trigger settings."""
|
||||||
|
|
||||||
name: str = "ai"
|
name: str = "ai"
|
||||||
owner: str = ""
|
owner: str = ""
|
||||||
respond_to_dms: bool = True
|
respond_to_dms: bool = True
|
||||||
filter_bbs_protocols: bool = True
|
filter_bbs_protocols: bool = True
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ConnectionConfig:
|
class ConnectionConfig:
|
||||||
"""Meshtastic connection settings."""
|
"""Meshtastic connection settings."""
|
||||||
|
|
||||||
type: str = "serial" # serial or tcp
|
type: str = "serial" # serial or tcp
|
||||||
serial_port: str = "/dev/ttyUSB0"
|
serial_port: str = "/dev/ttyUSB0"
|
||||||
tcp_host: str = "192.168.1.100"
|
tcp_host: str = "192.168.1.100"
|
||||||
tcp_port: int = 4403
|
tcp_port: int = 4403
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ResponseConfig:
|
class ResponseConfig:
|
||||||
"""Response behavior settings."""
|
"""Response behavior settings."""
|
||||||
|
|
||||||
delay_min: float = 2.2
|
delay_min: float = 2.2
|
||||||
delay_max: float = 3.0
|
delay_max: float = 3.0
|
||||||
max_length: int = 150
|
max_length: int = 150
|
||||||
max_messages: int = 2
|
max_messages: int = 2
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HistoryConfig:
|
class HistoryConfig:
|
||||||
"""Conversation history settings."""
|
"""Conversation history settings."""
|
||||||
|
|
||||||
database: str = "conversations.db"
|
database: str = "conversations.db"
|
||||||
max_messages_per_user: int = 50
|
max_messages_per_user: int = 50
|
||||||
conversation_timeout: int = 86400 # 24 hours
|
conversation_timeout: int = 86400 # 24 hours
|
||||||
|
|
||||||
# Cleanup settings
|
# Cleanup settings
|
||||||
auto_cleanup: bool = True
|
auto_cleanup: bool = True
|
||||||
cleanup_interval_hours: int = 24
|
cleanup_interval_hours: int = 24
|
||||||
max_age_days: int = 30 # Delete conversations older than this
|
max_age_days: int = 30 # Delete conversations older than this
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MemoryConfig:
|
class MemoryConfig:
|
||||||
"""Rolling summary memory settings."""
|
"""Rolling summary memory settings."""
|
||||||
|
|
||||||
enabled: bool = True # Enable memory optimization
|
enabled: bool = True # Enable memory optimization
|
||||||
window_size: int = 4 # Recent message pairs to keep in full
|
window_size: int = 4 # Recent message pairs to keep in full
|
||||||
summarize_threshold: int = 8 # Messages before re-summarizing
|
summarize_threshold: int = 8 # Messages before re-summarizing
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ContextConfig:
|
class ContextConfig:
|
||||||
"""Passive mesh context settings."""
|
"""Passive mesh context settings."""
|
||||||
|
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
observe_channels: list[int] = field(default_factory=list) # Empty = all channels
|
observe_channels: list[int] = field(default_factory=list) # Empty = all channels
|
||||||
ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore
|
ignore_nodes: list[str] = field(default_factory=list) # Node IDs to ignore
|
||||||
max_age: int = 2_592_000 # 30 days in seconds
|
max_age: int = 2_592_000 # 30 days in seconds
|
||||||
max_context_items: int = 20 # Max observations injected into LLM context
|
max_context_items: int = 20 # Max observations injected into LLM context
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandsConfig:
|
class CommandsConfig:
|
||||||
"""Command settings."""
|
"""Command settings."""
|
||||||
|
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
prefix: str = "!"
|
prefix: str = "!"
|
||||||
disabled_commands: list[str] = field(default_factory=list)
|
disabled_commands: list[str] = field(default_factory=list)
|
||||||
custom_commands: dict = field(default_factory=dict)
|
custom_commands: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LLMConfig:
|
class LLMConfig:
|
||||||
"""LLM backend settings."""
|
"""LLM backend settings."""
|
||||||
|
|
||||||
backend: str = "openai" # openai, anthropic, google
|
backend: str = "openai" # openai, anthropic, google
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
base_url: str = "https://api.openai.com/v1"
|
base_url: str = "https://api.openai.com/v1"
|
||||||
model: str = "gpt-4o-mini"
|
model: str = "gpt-4o-mini"
|
||||||
timeout: int = 30
|
timeout: int = 30
|
||||||
|
|
||||||
system_prompt: str = (
|
system_prompt: str = (
|
||||||
"YOUR COMMANDS (handled directly by you via DM):\n"
|
"YOUR COMMANDS (handled directly by you via DM):\n"
|
||||||
"!help — List available commands.\n"
|
"!help — List available commands.\n"
|
||||||
"!ping — Connectivity test, responds with pong.\n"
|
"!ping — Connectivity test, responds with pong.\n"
|
||||||
"!status — Shows your version, uptime, user count, and message count.\n"
|
"!status — Shows your version, uptime, user count, and message count.\n"
|
||||||
"!weather [location] — Weather lookup using Open-Meteo API.\n"
|
"!weather [location] — Weather lookup using Open-Meteo API.\n"
|
||||||
"!reset — Clears conversation history and memory.\n"
|
"!reset — Clears conversation history and memory.\n"
|
||||||
"!clear — Same as !reset.\n\n"
|
"!clear — Same as !reset.\n\n"
|
||||||
"YOUR ARCHITECTURE: Modular Python — pluggable LLM backends (OpenAI, Anthropic, "
|
"YOUR ARCHITECTURE: Modular Python — pluggable LLM backends (OpenAI, Anthropic, "
|
||||||
"Google, local), per-user SQLite conversation history, rolling summary memory, "
|
"Google, local), per-user SQLite conversation history, rolling summary memory, "
|
||||||
"passive mesh context buffer (observes channel traffic), smart chunking for LoRa "
|
"passive mesh context buffer (observes channel traffic), smart chunking for LoRa "
|
||||||
"message limits, prompt injection defense, advBBS filtering.\n\n"
|
"message limits, prompt injection defense, advBBS filtering.\n\n"
|
||||||
"RESPONSE RULES:\n"
|
"RESPONSE RULES:\n"
|
||||||
"- Keep responses very brief — 1-2 short sentences, under 300 characters. Only give longer answers if the user explicitly asks for detail or explanation.\n"
|
"- Keep responses very brief — 1-2 short sentences, under 300 characters. Only give longer answers if the user explicitly asks for detail or explanation.\n"
|
||||||
"- Be concise but friendly. No markdown formatting.\n"
|
"- Be concise but friendly. No markdown formatting.\n"
|
||||||
"- If asked about mesh activity and no recent traffic is shown, say you haven't "
|
"- If asked about mesh activity and no recent traffic is shown, say you haven't "
|
||||||
"observed any yet.\n"
|
"observed any yet.\n"
|
||||||
"- When asked about yourself or commands, answer conversationally. Don't dump lists.\n"
|
"- When asked about yourself or commands, answer conversationally. Don't dump lists.\n"
|
||||||
"- You are part of the freq51 mesh in the Twin Falls, Idaho area."
|
"- You are part of the freq51 mesh in the Twin Falls, Idaho area."
|
||||||
)
|
)
|
||||||
use_system_prompt: bool = True # Toggle to disable sending system prompt
|
use_system_prompt: bool = True # Toggle to disable sending system prompt
|
||||||
web_search: bool = False # Enable web search (Open WebUI feature)
|
web_search: bool = False # Enable web search (Open WebUI feature)
|
||||||
google_grounding: bool = False # Enable Google Search grounding (Gemini only)
|
google_grounding: bool = False # Enable Google Search grounding (Gemini only)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OpenMeteoConfig:
|
class OpenMeteoConfig:
|
||||||
"""Open-Meteo weather provider settings."""
|
"""Open-Meteo weather provider settings."""
|
||||||
|
|
||||||
url: str = "https://api.open-meteo.com/v1"
|
url: str = "https://api.open-meteo.com/v1"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WttrConfig:
|
class WttrConfig:
|
||||||
"""wttr.in weather provider settings."""
|
"""wttr.in weather provider settings."""
|
||||||
|
|
||||||
url: str = "https://wttr.in"
|
url: str = "https://wttr.in"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WeatherConfig:
|
class WeatherConfig:
|
||||||
"""Weather command settings."""
|
"""Weather command settings."""
|
||||||
|
|
||||||
primary: str = "openmeteo" # openmeteo, wttr, llm
|
primary: str = "openmeteo" # openmeteo, wttr, llm
|
||||||
fallback: str = "llm" # openmeteo, wttr, llm, none
|
fallback: str = "llm" # openmeteo, wttr, llm, none
|
||||||
default_location: str = ""
|
default_location: str = ""
|
||||||
openmeteo: OpenMeteoConfig = field(default_factory=OpenMeteoConfig)
|
openmeteo: OpenMeteoConfig = field(default_factory=OpenMeteoConfig)
|
||||||
wttr: WttrConfig = field(default_factory=WttrConfig)
|
wttr: WttrConfig = field(default_factory=WttrConfig)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MeshMonitorConfig:
|
class MeshMonitorConfig:
|
||||||
"""MeshMonitor trigger sync settings."""
|
"""MeshMonitor trigger sync settings."""
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
url: str = "" # e.g., http://100.64.0.11:3333
|
url: str = "" # e.g., http://100.64.0.11:3333
|
||||||
inject_into_prompt: bool = True # Tell LLM about MeshMonitor commands
|
inject_into_prompt: bool = True # Tell LLM about MeshMonitor commands
|
||||||
refresh_interval: int = 300 # Seconds between refreshes
|
refresh_interval: int = 300 # Seconds between refreshes
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class KnowledgeConfig:
|
class KnowledgeConfig:
|
||||||
"""FTS5 knowledge base settings."""
|
"""FTS5 knowledge base settings."""
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
db_path: str = ""
|
db_path: str = ""
|
||||||
top_k: int = 5
|
top_k: int = 5
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Config:
|
@dataclass
|
||||||
"""Main configuration container."""
|
class MeshSourceConfig:
|
||||||
|
"""Configuration for a mesh data source."""
|
||||||
bot: BotConfig = field(default_factory=BotConfig)
|
|
||||||
connection: ConnectionConfig = field(default_factory=ConnectionConfig)
|
name: str = ""
|
||||||
response: ResponseConfig = field(default_factory=ResponseConfig)
|
type: str = "" # "meshview" or "meshmonitor"
|
||||||
history: HistoryConfig = field(default_factory=HistoryConfig)
|
url: str = ""
|
||||||
memory: MemoryConfig = field(default_factory=MemoryConfig)
|
api_token: str = "" # MeshMonitor only, supports ${ENV_VAR}
|
||||||
context: ContextConfig = field(default_factory=ContextConfig)
|
refresh_interval: int = 300
|
||||||
commands: CommandsConfig = field(default_factory=CommandsConfig)
|
enabled: bool = True
|
||||||
llm: LLMConfig = field(default_factory=LLMConfig)
|
|
||||||
weather: WeatherConfig = field(default_factory=WeatherConfig)
|
|
||||||
meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig)
|
@dataclass
|
||||||
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
|
class Config:
|
||||||
|
"""Main configuration container."""
|
||||||
_config_path: Optional[Path] = field(default=None, repr=False)
|
|
||||||
|
bot: BotConfig = field(default_factory=BotConfig)
|
||||||
def resolve_api_key(self) -> str:
|
connection: ConnectionConfig = field(default_factory=ConnectionConfig)
|
||||||
"""Resolve API key from config or environment."""
|
response: ResponseConfig = field(default_factory=ResponseConfig)
|
||||||
if self.llm.api_key:
|
history: HistoryConfig = field(default_factory=HistoryConfig)
|
||||||
# Check if it's an env var reference like ${LLM_API_KEY}
|
memory: MemoryConfig = field(default_factory=MemoryConfig)
|
||||||
if self.llm.api_key.startswith("${") and self.llm.api_key.endswith("}"):
|
context: ContextConfig = field(default_factory=ContextConfig)
|
||||||
env_var = self.llm.api_key[2:-1]
|
commands: CommandsConfig = field(default_factory=CommandsConfig)
|
||||||
return os.environ.get(env_var, "")
|
llm: LLMConfig = field(default_factory=LLMConfig)
|
||||||
return self.llm.api_key
|
weather: WeatherConfig = field(default_factory=WeatherConfig)
|
||||||
# Fall back to common env vars
|
meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig)
|
||||||
for env_var in ["LLM_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]:
|
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
|
||||||
if value := os.environ.get(env_var):
|
mesh_sources: list[MeshSourceConfig] = field(default_factory=list)
|
||||||
return value
|
|
||||||
return ""
|
_config_path: Optional[Path] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
def resolve_api_key(self) -> str:
|
||||||
def _dict_to_dataclass(cls, data: dict):
|
"""Resolve API key from config or environment."""
|
||||||
"""Recursively convert dict to dataclass, handling nested structures."""
|
if self.llm.api_key:
|
||||||
if data is None:
|
# Check if it's an env var reference like ${LLM_API_KEY}
|
||||||
return cls()
|
if self.llm.api_key.startswith("${") and self.llm.api_key.endswith("}"):
|
||||||
|
env_var = self.llm.api_key[2:-1]
|
||||||
field_types = {f.name: f.type for f in cls.__dataclass_fields__.values()}
|
return os.environ.get(env_var, "")
|
||||||
kwargs = {}
|
return self.llm.api_key
|
||||||
|
# Fall back to common env vars
|
||||||
for key, value in data.items():
|
for env_var in ["LLM_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]:
|
||||||
if key.startswith("_"):
|
if value := os.environ.get(env_var):
|
||||||
continue
|
return value
|
||||||
if key not in field_types:
|
return ""
|
||||||
continue
|
|
||||||
|
|
||||||
field_type = field_types[key]
|
def _dict_to_dataclass(cls, data: dict):
|
||||||
|
"""Recursively convert dict to dataclass, handling nested structures."""
|
||||||
# Handle nested dataclasses
|
if data is None:
|
||||||
if hasattr(field_type, "__dataclass_fields__") and isinstance(value, dict):
|
return cls()
|
||||||
kwargs[key] = _dict_to_dataclass(field_type, value)
|
|
||||||
else:
|
field_types = {f.name: f.type for f in cls.__dataclass_fields__.values()}
|
||||||
kwargs[key] = value
|
kwargs = {}
|
||||||
|
|
||||||
return cls(**kwargs)
|
for key, value in data.items():
|
||||||
|
if key.startswith("_"):
|
||||||
|
continue
|
||||||
def _dataclass_to_dict(obj) -> dict:
|
if key not in field_types:
|
||||||
"""Recursively convert dataclass to dict for YAML serialization."""
|
continue
|
||||||
if not hasattr(obj, "__dataclass_fields__"):
|
|
||||||
return obj
|
field_type = field_types[key]
|
||||||
|
|
||||||
result = {}
|
# Handle nested dataclasses
|
||||||
for field_name in obj.__dataclass_fields__:
|
if hasattr(field_type, "__dataclass_fields__") and isinstance(value, dict):
|
||||||
if field_name.startswith("_"):
|
kwargs[key] = _dict_to_dataclass(field_type, value)
|
||||||
continue
|
# Handle list of MeshSourceConfig
|
||||||
value = getattr(obj, field_name)
|
elif key == "mesh_sources" and isinstance(value, list):
|
||||||
if hasattr(value, "__dataclass_fields__"):
|
kwargs[key] = [
|
||||||
result[field_name] = _dataclass_to_dict(value)
|
_dict_to_dataclass(MeshSourceConfig, item)
|
||||||
elif isinstance(value, list):
|
if isinstance(item, dict) else item
|
||||||
result[field_name] = list(value)
|
for item in value
|
||||||
else:
|
]
|
||||||
result[field_name] = value
|
else:
|
||||||
return result
|
kwargs[key] = value
|
||||||
|
|
||||||
|
return cls(**kwargs)
|
||||||
def load_config(config_path: Optional[Path] = None) -> Config:
|
|
||||||
"""Load configuration from YAML file.
|
|
||||||
|
def _dataclass_to_dict(obj) -> dict:
|
||||||
Args:
|
"""Recursively convert dataclass to dict for YAML serialization."""
|
||||||
config_path: Path to config file. Defaults to ./config.yaml
|
if not hasattr(obj, "__dataclass_fields__"):
|
||||||
|
return obj
|
||||||
Returns:
|
|
||||||
Config object with loaded settings
|
result = {}
|
||||||
"""
|
for field_name in obj.__dataclass_fields__:
|
||||||
if config_path is None:
|
if field_name.startswith("_"):
|
||||||
config_path = Path("config.yaml")
|
continue
|
||||||
|
value = getattr(obj, field_name)
|
||||||
config_path = Path(config_path)
|
if hasattr(value, "__dataclass_fields__"):
|
||||||
|
result[field_name] = _dataclass_to_dict(value)
|
||||||
if not config_path.exists():
|
elif isinstance(value, list):
|
||||||
# Return default config if file doesn't exist
|
# Handle list of dataclasses (like mesh_sources)
|
||||||
config = Config()
|
result[field_name] = [
|
||||||
config._config_path = config_path
|
_dataclass_to_dict(item) if hasattr(item, "__dataclass_fields__") else item
|
||||||
return config
|
for item in value
|
||||||
|
]
|
||||||
with open(config_path, "r") as f:
|
else:
|
||||||
data = yaml.safe_load(f) or {}
|
result[field_name] = value
|
||||||
|
return result
|
||||||
config = _dict_to_dataclass(Config, data)
|
|
||||||
config._config_path = config_path
|
|
||||||
return config
|
def load_config(config_path: Optional[Path] = None) -> Config:
|
||||||
|
"""Load configuration from YAML file.
|
||||||
|
|
||||||
def save_config(config: Config, config_path: Optional[Path] = None) -> None:
|
Args:
|
||||||
"""Save configuration to YAML file.
|
config_path: Path to config file. Defaults to ./config.yaml
|
||||||
|
|
||||||
Args:
|
Returns:
|
||||||
config: Config object to save
|
Config object with loaded settings
|
||||||
config_path: Path to save to. Uses config._config_path if not specified
|
"""
|
||||||
"""
|
if config_path is None:
|
||||||
if config_path is None:
|
config_path = Path("config.yaml")
|
||||||
config_path = config._config_path or Path("config.yaml")
|
|
||||||
|
config_path = Path(config_path)
|
||||||
config_path = Path(config_path)
|
|
||||||
|
if not config_path.exists():
|
||||||
data = _dataclass_to_dict(config)
|
# Return default config if file doesn't exist
|
||||||
|
config = Config()
|
||||||
# Add header comment
|
config._config_path = config_path
|
||||||
header = "# MeshAI Configuration\n# Generated by meshai --config\n\n"
|
return config
|
||||||
|
|
||||||
with open(config_path, "w") as f:
|
with open(config_path, "r") as f:
|
||||||
f.write(header)
|
data = yaml.safe_load(f) or {}
|
||||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
||||||
|
config = _dict_to_dataclass(Config, data)
|
||||||
|
config._config_path = config_path
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(config: Config, config_path: Optional[Path] = None) -> None:
|
||||||
|
"""Save configuration to YAML file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Config object to save
|
||||||
|
config_path: Path to save to. Uses config._config_path if not specified
|
||||||
|
"""
|
||||||
|
if config_path is None:
|
||||||
|
config_path = config._config_path or Path("config.yaml")
|
||||||
|
|
||||||
|
config_path = Path(config_path)
|
||||||
|
|
||||||
|
data = _dataclass_to_dict(config)
|
||||||
|
|
||||||
|
# Add header comment
|
||||||
|
header = "# MeshAI Configuration\n# Generated by meshai --config\n\n"
|
||||||
|
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
f.write(header)
|
||||||
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
||||||
|
|
|
||||||
790
meshai/main.py
790
meshai/main.py
|
|
@ -1,381 +1,409 @@
|
||||||
"""Main entry point for MeshAI."""
|
"""Main entry point for MeshAI."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .backends import AnthropicBackend, GoogleBackend, LLMBackend, OpenAIBackend
|
from .backends import AnthropicBackend, GoogleBackend, LLMBackend, OpenAIBackend
|
||||||
from .cli import run_configurator
|
from .cli import run_configurator
|
||||||
from .commands import CommandDispatcher
|
from .commands import CommandDispatcher
|
||||||
from .commands.dispatcher import create_dispatcher
|
from .commands.dispatcher import create_dispatcher
|
||||||
from .commands.status import set_start_time
|
from .commands.status import set_start_time
|
||||||
from .config import Config, load_config
|
from .config import Config, load_config
|
||||||
from .connector import MeshConnector, MeshMessage
|
from .connector import MeshConnector, MeshMessage
|
||||||
from .context import MeshContext
|
from .context import MeshContext
|
||||||
from .history import ConversationHistory
|
from .history import ConversationHistory
|
||||||
from .memory import ConversationSummary
|
from .memory import ConversationSummary
|
||||||
from .responder import Responder
|
from .responder import Responder
|
||||||
from .router import MessageRouter, RouteType
|
from .router import MessageRouter, RouteType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MeshAI:
|
class MeshAI:
|
||||||
"""Main application class."""
|
"""Main application class."""
|
||||||
|
|
||||||
def __init__(self, config: Config):
|
def __init__(self, config: Config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.connector: Optional[MeshConnector] = None
|
self.connector: Optional[MeshConnector] = None
|
||||||
self.history: Optional[ConversationHistory] = None
|
self.history: Optional[ConversationHistory] = None
|
||||||
self.dispatcher: Optional[CommandDispatcher] = None
|
self.dispatcher: Optional[CommandDispatcher] = None
|
||||||
self.llm: Optional[LLMBackend] = None
|
self.llm: Optional[LLMBackend] = None
|
||||||
self.context: Optional[MeshContext] = None
|
self.context: Optional[MeshContext] = None
|
||||||
self.meshmonitor_sync = None
|
self.meshmonitor_sync = None
|
||||||
self.knowledge = None
|
self.knowledge = None
|
||||||
self.router: Optional[MessageRouter] = None
|
self.source_manager = None
|
||||||
self.responder: Optional[Responder] = None
|
self.router: Optional[MessageRouter] = None
|
||||||
self._running = False
|
self.responder: Optional[Responder] = None
|
||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
self._running = False
|
||||||
self._last_cleanup: float = 0.0
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
self._last_cleanup: float = 0.0
|
||||||
async def start(self) -> None:
|
|
||||||
"""Start the bot."""
|
async def start(self) -> None:
|
||||||
logger.info(f"Starting MeshAI v{__version__}")
|
"""Start the bot."""
|
||||||
set_start_time(time.time())
|
logger.info(f"Starting MeshAI v{__version__}")
|
||||||
|
set_start_time(time.time())
|
||||||
# Initialize components
|
|
||||||
await self._init_components()
|
# Initialize components
|
||||||
|
await self._init_components()
|
||||||
# Connect to Meshtastic
|
|
||||||
self.connector.connect()
|
# Connect to Meshtastic
|
||||||
self.connector.set_message_callback(self._on_message, asyncio.get_event_loop())
|
self.connector.connect()
|
||||||
|
self.connector.set_message_callback(self._on_message, asyncio.get_event_loop())
|
||||||
# Add own node ID to context ignore list
|
|
||||||
if self.context and self.connector.my_node_id:
|
# Add own node ID to context ignore list
|
||||||
self.context._ignore_nodes.add(self.connector.my_node_id)
|
if self.context and self.connector.my_node_id:
|
||||||
|
self.context._ignore_nodes.add(self.connector.my_node_id)
|
||||||
self._running = True
|
|
||||||
self._loop = asyncio.get_event_loop()
|
self._running = True
|
||||||
self._last_cleanup = time.time()
|
self._loop = asyncio.get_event_loop()
|
||||||
|
self._last_cleanup = time.time()
|
||||||
# Write PID file
|
|
||||||
self._write_pid()
|
# Write PID file
|
||||||
|
self._write_pid()
|
||||||
logger.info("MeshAI started successfully")
|
|
||||||
|
logger.info("MeshAI started successfully")
|
||||||
# Keep running
|
|
||||||
while self._running:
|
# Keep running
|
||||||
await asyncio.sleep(1)
|
while self._running:
|
||||||
|
await asyncio.sleep(1)
|
||||||
# Periodic MeshMonitor refresh
|
|
||||||
if self.meshmonitor_sync:
|
# Periodic MeshMonitor refresh
|
||||||
self.meshmonitor_sync.maybe_refresh()
|
if self.meshmonitor_sync:
|
||||||
|
self.meshmonitor_sync.maybe_refresh()
|
||||||
# Periodic cleanup
|
|
||||||
if time.time() - self._last_cleanup >= 3600:
|
# Periodic mesh source refresh
|
||||||
await self.history.cleanup_expired()
|
if self.source_manager:
|
||||||
if self.context:
|
self.source_manager.refresh_all()
|
||||||
self.context.prune()
|
|
||||||
self._last_cleanup = time.time()
|
# Periodic cleanup
|
||||||
|
if time.time() - self._last_cleanup >= 3600:
|
||||||
async def stop(self) -> None:
|
await self.history.cleanup_expired()
|
||||||
"""Stop the bot."""
|
if self.context:
|
||||||
logger.info("Stopping MeshAI...")
|
self.context.prune()
|
||||||
self._running = False
|
self._last_cleanup = time.time()
|
||||||
|
|
||||||
if self.connector:
|
async def stop(self) -> None:
|
||||||
self.connector.disconnect()
|
"""Stop the bot."""
|
||||||
|
logger.info("Stopping MeshAI...")
|
||||||
if self.history:
|
self._running = False
|
||||||
await self.history.close()
|
|
||||||
|
if self.connector:
|
||||||
if self.llm:
|
self.connector.disconnect()
|
||||||
await self.llm.close()
|
|
||||||
if self.knowledge:
|
if self.history:
|
||||||
self.knowledge.close()
|
await self.history.close()
|
||||||
|
|
||||||
self._remove_pid()
|
if self.llm:
|
||||||
logger.info("MeshAI stopped")
|
await self.llm.close()
|
||||||
|
if self.knowledge:
|
||||||
async def _init_components(self) -> None:
|
self.knowledge.close()
|
||||||
"""Initialize all components."""
|
|
||||||
# Conversation history
|
self._remove_pid()
|
||||||
self.history = ConversationHistory(self.config.history)
|
logger.info("MeshAI stopped")
|
||||||
await self.history.initialize()
|
|
||||||
|
async def _init_components(self) -> None:
|
||||||
# Command dispatcher
|
"""Initialize all components."""
|
||||||
self.dispatcher = create_dispatcher(
|
# Conversation history
|
||||||
prefix=self.config.commands.prefix,
|
self.history = ConversationHistory(self.config.history)
|
||||||
disabled_commands=self.config.commands.disabled_commands,
|
await self.history.initialize()
|
||||||
custom_commands=self.config.commands.custom_commands,
|
|
||||||
)
|
# Command dispatcher
|
||||||
|
self.dispatcher = create_dispatcher(
|
||||||
# LLM backend
|
prefix=self.config.commands.prefix,
|
||||||
api_key = self.config.resolve_api_key()
|
disabled_commands=self.config.commands.disabled_commands,
|
||||||
if not api_key:
|
custom_commands=self.config.commands.custom_commands,
|
||||||
logger.warning("No API key configured - LLM responses will fail")
|
)
|
||||||
|
|
||||||
# Memory config
|
# LLM backend
|
||||||
mem_cfg = self.config.memory
|
api_key = self.config.resolve_api_key()
|
||||||
window_size = mem_cfg.window_size if mem_cfg.enabled else 0
|
if not api_key:
|
||||||
summarize_threshold = mem_cfg.summarize_threshold
|
logger.warning("No API key configured - LLM responses will fail")
|
||||||
|
|
||||||
# Create backend
|
# Memory config
|
||||||
backend = self.config.llm.backend.lower()
|
mem_cfg = self.config.memory
|
||||||
if backend == "openai":
|
window_size = mem_cfg.window_size if mem_cfg.enabled else 0
|
||||||
self.llm = OpenAIBackend(
|
summarize_threshold = mem_cfg.summarize_threshold
|
||||||
self.config.llm, api_key, window_size, summarize_threshold
|
|
||||||
)
|
# Create backend
|
||||||
elif backend == "anthropic":
|
backend = self.config.llm.backend.lower()
|
||||||
self.llm = AnthropicBackend(
|
if backend == "openai":
|
||||||
self.config.llm, api_key, window_size, summarize_threshold
|
self.llm = OpenAIBackend(
|
||||||
)
|
self.config.llm, api_key, window_size, summarize_threshold
|
||||||
elif backend == "google":
|
)
|
||||||
self.llm = GoogleBackend(
|
elif backend == "anthropic":
|
||||||
self.config.llm, api_key, window_size, summarize_threshold
|
self.llm = AnthropicBackend(
|
||||||
)
|
self.config.llm, api_key, window_size, summarize_threshold
|
||||||
else:
|
)
|
||||||
logger.warning(f"Unknown backend '{backend}', defaulting to OpenAI")
|
elif backend == "google":
|
||||||
self.llm = OpenAIBackend(
|
self.llm = GoogleBackend(
|
||||||
self.config.llm, api_key, window_size, summarize_threshold
|
self.config.llm, api_key, window_size, summarize_threshold
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
# Load persisted summaries into memory cache
|
logger.warning(f"Unknown backend '{backend}', defaulting to OpenAI")
|
||||||
await self._load_summaries()
|
self.llm = OpenAIBackend(
|
||||||
|
self.config.llm, api_key, window_size, summarize_threshold
|
||||||
# Meshtastic connector
|
)
|
||||||
self.connector = MeshConnector(self.config.connection)
|
|
||||||
|
# Load persisted summaries into memory cache
|
||||||
# Passive mesh context buffer
|
await self._load_summaries()
|
||||||
ctx_cfg = self.config.context
|
|
||||||
if ctx_cfg.enabled:
|
# Meshtastic connector
|
||||||
self.context = MeshContext(
|
self.connector = MeshConnector(self.config.connection)
|
||||||
observe_channels=ctx_cfg.observe_channels or None,
|
|
||||||
ignore_nodes=ctx_cfg.ignore_nodes or None,
|
# Passive mesh context buffer
|
||||||
max_age=ctx_cfg.max_age,
|
ctx_cfg = self.config.context
|
||||||
)
|
if ctx_cfg.enabled:
|
||||||
logger.info("Mesh context buffer enabled")
|
self.context = MeshContext(
|
||||||
else:
|
observe_channels=ctx_cfg.observe_channels or None,
|
||||||
self.context = None
|
ignore_nodes=ctx_cfg.ignore_nodes or None,
|
||||||
|
max_age=ctx_cfg.max_age,
|
||||||
# MeshMonitor trigger sync
|
)
|
||||||
mm_cfg = self.config.meshmonitor
|
logger.info("Mesh context buffer enabled")
|
||||||
if mm_cfg.enabled and mm_cfg.url:
|
else:
|
||||||
from .meshmonitor import MeshMonitorSync
|
self.context = None
|
||||||
self.meshmonitor_sync = MeshMonitorSync(
|
|
||||||
url=mm_cfg.url,
|
# MeshMonitor trigger sync
|
||||||
refresh_interval=mm_cfg.refresh_interval,
|
mm_cfg = self.config.meshmonitor
|
||||||
)
|
if mm_cfg.enabled and mm_cfg.url:
|
||||||
count = self.meshmonitor_sync.load()
|
from .meshmonitor import MeshMonitorSync
|
||||||
logger.info(f"MeshMonitor sync enabled, loaded {count} triggers")
|
self.meshmonitor_sync = MeshMonitorSync(
|
||||||
else:
|
url=mm_cfg.url,
|
||||||
self.meshmonitor_sync = None
|
refresh_interval=mm_cfg.refresh_interval,
|
||||||
|
)
|
||||||
# Knowledge base
|
count = self.meshmonitor_sync.load()
|
||||||
kb_cfg = self.config.knowledge
|
logger.info(f"MeshMonitor sync enabled, loaded {count} triggers")
|
||||||
if kb_cfg.enabled and kb_cfg.db_path:
|
else:
|
||||||
from .knowledge import KnowledgeSearch
|
self.meshmonitor_sync = None
|
||||||
self.knowledge = KnowledgeSearch(
|
|
||||||
db_path=kb_cfg.db_path,
|
# Mesh data sources
|
||||||
top_k=kb_cfg.top_k,
|
enabled_sources = [s for s in self.config.mesh_sources if s.enabled]
|
||||||
)
|
if enabled_sources:
|
||||||
else:
|
from .mesh_sources import MeshSourceManager
|
||||||
self.knowledge = None
|
self.source_manager = MeshSourceManager(enabled_sources)
|
||||||
|
# Initial fetch
|
||||||
# Message router
|
self.source_manager.refresh_all()
|
||||||
self.router = MessageRouter(
|
# Log status
|
||||||
self.config, self.connector, self.history, self.dispatcher, self.llm,
|
for status in self.source_manager.get_status():
|
||||||
context=self.context,
|
if status["is_loaded"]:
|
||||||
meshmonitor_sync=self.meshmonitor_sync,
|
logger.info(
|
||||||
knowledge=self.knowledge,
|
f"Mesh source '{status['name']}' ({status['type']}): "
|
||||||
)
|
f"{status['node_count']} nodes"
|
||||||
|
)
|
||||||
# Responder
|
else:
|
||||||
self.responder = Responder(self.config.response, self.connector)
|
logger.warning(
|
||||||
|
f"Mesh source '{status['name']}' ({status['type']}): "
|
||||||
async def _on_message(self, message: MeshMessage) -> None:
|
f"failed - {status.get('last_error', 'unknown error')}"
|
||||||
"""Handle incoming message."""
|
)
|
||||||
try:
|
else:
|
||||||
# Passively observe channel broadcasts for context (before filtering)
|
self.source_manager = None
|
||||||
if self.context and not message.is_dm and message.text:
|
|
||||||
self.context.observe(
|
# Knowledge base
|
||||||
sender_name=message.sender_name,
|
kb_cfg = self.config.knowledge
|
||||||
sender_id=message.sender_id,
|
if kb_cfg.enabled and kb_cfg.db_path:
|
||||||
text=message.text,
|
from .knowledge import KnowledgeSearch
|
||||||
channel=message.channel,
|
self.knowledge = KnowledgeSearch(
|
||||||
is_dm=False,
|
db_path=kb_cfg.db_path,
|
||||||
)
|
top_k=kb_cfg.top_k,
|
||||||
|
)
|
||||||
# Check if we should respond
|
else:
|
||||||
if not self.router.should_respond(message):
|
self.knowledge = None
|
||||||
return
|
|
||||||
|
# Message router
|
||||||
logger.info(
|
self.router = MessageRouter(
|
||||||
f"Processing message from {message.sender_name} ({message.sender_id}): "
|
self.config, self.connector, self.history, self.dispatcher, self.llm,
|
||||||
f"{message.text[:50]}..."
|
context=self.context,
|
||||||
)
|
meshmonitor_sync=self.meshmonitor_sync,
|
||||||
|
knowledge=self.knowledge,
|
||||||
# Route the message
|
source_manager=self.source_manager,
|
||||||
# Check for continuation request first
|
)
|
||||||
continuation_messages = self.router.check_continuation(message)
|
|
||||||
if continuation_messages:
|
# Responder
|
||||||
await self.responder.send_response(
|
self.responder = Responder(self.config.response, self.connector)
|
||||||
continuation_messages,
|
|
||||||
destination=message.sender_id,
|
async def _on_message(self, message: MeshMessage) -> None:
|
||||||
channel=message.channel,
|
"""Handle incoming message."""
|
||||||
)
|
try:
|
||||||
return
|
# Passively observe channel broadcasts for context (before filtering)
|
||||||
|
if self.context and not message.is_dm and message.text:
|
||||||
result = await self.router.route(message)
|
self.context.observe(
|
||||||
|
sender_name=message.sender_name,
|
||||||
if result.route_type == RouteType.IGNORE:
|
sender_id=message.sender_id,
|
||||||
return
|
text=message.text,
|
||||||
|
channel=message.channel,
|
||||||
# Determine response
|
is_dm=False,
|
||||||
if result.route_type == RouteType.COMMAND:
|
)
|
||||||
messages = result.response # Commands return single string
|
|
||||||
elif result.route_type == RouteType.LLM:
|
# Check if we should respond
|
||||||
messages = await self.router.generate_llm_response(message, result.query)
|
if not self.router.should_respond(message):
|
||||||
else:
|
return
|
||||||
return
|
|
||||||
|
logger.info(
|
||||||
if not messages:
|
f"Processing message from {message.sender_name} ({message.sender_id}): "
|
||||||
return
|
f"{message.text[:50]}..."
|
||||||
|
)
|
||||||
# Send DM response
|
|
||||||
await self.responder.send_response(
|
# Route the message
|
||||||
messages,
|
# Check for continuation request first
|
||||||
destination=message.sender_id,
|
continuation_messages = self.router.check_continuation(message)
|
||||||
channel=message.channel,
|
if continuation_messages:
|
||||||
)
|
await self.responder.send_response(
|
||||||
|
continuation_messages,
|
||||||
except Exception as e:
|
destination=message.sender_id,
|
||||||
logger.error(f"Error handling message: {e}", exc_info=True)
|
channel=message.channel,
|
||||||
|
)
|
||||||
async def _load_summaries(self) -> None:
|
return
|
||||||
"""Load persisted summaries from database into memory cache."""
|
|
||||||
memory = self.llm.get_memory()
|
result = await self.router.route(message)
|
||||||
if not memory:
|
|
||||||
return
|
if result.route_type == RouteType.IGNORE:
|
||||||
|
return
|
||||||
if not self.history or not self.history._db:
|
|
||||||
return
|
# Determine response
|
||||||
|
if result.route_type == RouteType.COMMAND:
|
||||||
try:
|
messages = result.response # Commands return single string
|
||||||
async with self.history._lock:
|
elif result.route_type == RouteType.LLM:
|
||||||
cursor = await self.history._db.execute(
|
messages = await self.router.generate_llm_response(message, result.query)
|
||||||
"SELECT user_id, summary, message_count, updated_at "
|
else:
|
||||||
"FROM conversation_summaries"
|
return
|
||||||
)
|
|
||||||
rows = await cursor.fetchall()
|
if not messages:
|
||||||
|
return
|
||||||
loaded = 0
|
|
||||||
for row in rows:
|
# Send DM response
|
||||||
user_id, summary_text, message_count, updated_at = row
|
await self.responder.send_response(
|
||||||
summary = ConversationSummary(
|
messages,
|
||||||
summary=summary_text,
|
destination=message.sender_id,
|
||||||
last_updated=updated_at,
|
channel=message.channel,
|
||||||
message_count=message_count,
|
)
|
||||||
)
|
|
||||||
memory.load_summary(user_id, summary)
|
except Exception as e:
|
||||||
loaded += 1
|
logger.error(f"Error handling message: {e}", exc_info=True)
|
||||||
|
|
||||||
if loaded:
|
async def _load_summaries(self) -> None:
|
||||||
logger.info(f"Loaded {loaded} conversation summaries from database")
|
"""Load persisted summaries from database into memory cache."""
|
||||||
|
memory = self.llm.get_memory()
|
||||||
except Exception as e:
|
if not memory:
|
||||||
logger.warning(f"Failed to load summaries from database: {e}")
|
return
|
||||||
|
|
||||||
def _write_pid(self) -> None:
|
if not self.history or not self.history._db:
|
||||||
"""Write PID file."""
|
return
|
||||||
pid_file = Path("/tmp/meshai.pid")
|
|
||||||
pid_file.write_text(str(os.getpid()))
|
try:
|
||||||
|
async with self.history._lock:
|
||||||
def _remove_pid(self) -> None:
|
cursor = await self.history._db.execute(
|
||||||
"""Remove PID file."""
|
"SELECT user_id, summary, message_count, updated_at "
|
||||||
pid_file = Path("/tmp/meshai.pid")
|
"FROM conversation_summaries"
|
||||||
if pid_file.exists():
|
)
|
||||||
pid_file.unlink()
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
loaded = 0
|
||||||
def setup_logging(verbose: bool = False) -> None:
|
for row in rows:
|
||||||
"""Configure logging."""
|
user_id, summary_text, message_count, updated_at = row
|
||||||
level = logging.DEBUG if verbose else logging.INFO
|
summary = ConversationSummary(
|
||||||
logging.basicConfig(
|
summary=summary_text,
|
||||||
level=level,
|
last_updated=updated_at,
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
message_count=message_count,
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
)
|
||||||
)
|
memory.load_summary(user_id, summary)
|
||||||
|
loaded += 1
|
||||||
|
|
||||||
def main() -> None:
|
if loaded:
|
||||||
"""Main entry point."""
|
logger.info(f"Loaded {loaded} conversation summaries from database")
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="MeshAI - LLM-powered Meshtastic assistant",
|
except Exception as e:
|
||||||
prog="meshai",
|
logger.warning(f"Failed to load summaries from database: {e}")
|
||||||
)
|
|
||||||
parser.add_argument(
|
def _write_pid(self) -> None:
|
||||||
"--version", "-V", action="version", version=f"%(prog)s {__version__}"
|
"""Write PID file."""
|
||||||
)
|
pid_file = Path("/tmp/meshai.pid")
|
||||||
parser.add_argument(
|
pid_file.write_text(str(os.getpid()))
|
||||||
"--config", "-c", action="store_true", help="Launch configuration tool"
|
|
||||||
)
|
def _remove_pid(self) -> None:
|
||||||
parser.add_argument(
|
"""Remove PID file."""
|
||||||
"--config-file",
|
pid_file = Path("/tmp/meshai.pid")
|
||||||
"-f",
|
if pid_file.exists():
|
||||||
type=Path,
|
pid_file.unlink()
|
||||||
default=Path("config.yaml"),
|
|
||||||
help="Path to config file (default: config.yaml)",
|
|
||||||
)
|
def setup_logging(verbose: bool = False) -> None:
|
||||||
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
|
"""Configure logging."""
|
||||||
|
level = logging.DEBUG if verbose else logging.INFO
|
||||||
args = parser.parse_args()
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
setup_logging(args.verbose)
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
# Launch configurator if requested
|
)
|
||||||
if args.config:
|
|
||||||
run_configurator(args.config_file)
|
|
||||||
return
|
def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
# Load config
|
parser = argparse.ArgumentParser(
|
||||||
config = load_config(args.config_file)
|
description="MeshAI - LLM-powered Meshtastic assistant",
|
||||||
|
prog="meshai",
|
||||||
# Check if config exists
|
)
|
||||||
if not args.config_file.exists():
|
parser.add_argument(
|
||||||
logger.warning(f"Config file not found: {args.config_file}")
|
"--version", "-V", action="version", version=f"%(prog)s {__version__}"
|
||||||
logger.info("Run 'meshai --config' to create one, or copy config.example.yaml")
|
)
|
||||||
sys.exit(1)
|
parser.add_argument(
|
||||||
|
"--config", "-c", action="store_true", help="Launch configuration tool"
|
||||||
# Create and run bot
|
)
|
||||||
bot = MeshAI(config)
|
parser.add_argument(
|
||||||
|
"--config-file",
|
||||||
# Handle signals
|
"-f",
|
||||||
loop = asyncio.new_event_loop()
|
type=Path,
|
||||||
asyncio.set_event_loop(loop)
|
default=Path("config.yaml"),
|
||||||
|
help="Path to config file (default: config.yaml)",
|
||||||
def signal_handler(sig, frame):
|
)
|
||||||
logger.info(f"Received signal {sig}")
|
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
|
||||||
loop.create_task(bot.stop())
|
|
||||||
|
args = parser.parse_args()
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
setup_logging(args.verbose)
|
||||||
|
|
||||||
try:
|
# Launch configurator if requested
|
||||||
loop.run_until_complete(bot.start())
|
if args.config:
|
||||||
except KeyboardInterrupt:
|
run_configurator(args.config_file)
|
||||||
pass
|
return
|
||||||
finally:
|
|
||||||
loop.run_until_complete(bot.stop())
|
# Load config
|
||||||
loop.close()
|
config = load_config(args.config_file)
|
||||||
|
|
||||||
|
# Check if config exists
|
||||||
if __name__ == "__main__":
|
if not args.config_file.exists():
|
||||||
main()
|
logger.warning(f"Config file not found: {args.config_file}")
|
||||||
|
logger.info("Run 'meshai --config' to create one, or copy config.example.yaml")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Create and run bot
|
||||||
|
bot = MeshAI(config)
|
||||||
|
|
||||||
|
# Handle signals
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
def signal_handler(sig, frame):
|
||||||
|
logger.info(f"Received signal {sig}")
|
||||||
|
loop.create_task(bot.stop())
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(bot.start())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
loop.run_until_complete(bot.stop())
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
||||||
197
meshai/mesh_sources.py
Normal file
197
meshai/mesh_sources.py
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
"""Mesh data source manager."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .config import MeshSourceConfig
|
||||||
|
from .sources.meshview import MeshviewSource
|
||||||
|
from .sources.meshmonitor_data import MeshMonitorDataSource
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MeshSourceManager:
|
||||||
|
"""Manages multiple mesh data sources."""
|
||||||
|
|
||||||
|
def __init__(self, source_configs: list[MeshSourceConfig]):
|
||||||
|
"""Initialize source manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_configs: List of source configurations
|
||||||
|
"""
|
||||||
|
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource] = {}
|
||||||
|
|
||||||
|
for cfg in source_configs:
|
||||||
|
if not cfg.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = cfg.name
|
||||||
|
if not name:
|
||||||
|
logger.warning("Skipping source with empty name")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if name in self._sources:
|
||||||
|
logger.warning(f"Duplicate source name '{name}', skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if cfg.type == "meshview":
|
||||||
|
self._sources[name] = MeshviewSource(
|
||||||
|
url=cfg.url,
|
||||||
|
refresh_interval=cfg.refresh_interval,
|
||||||
|
)
|
||||||
|
logger.info(f"Created Meshview source '{name}' -> {cfg.url}")
|
||||||
|
|
||||||
|
elif cfg.type == "meshmonitor":
|
||||||
|
self._sources[name] = MeshMonitorDataSource(
|
||||||
|
url=cfg.url,
|
||||||
|
api_token=cfg.api_token,
|
||||||
|
refresh_interval=cfg.refresh_interval,
|
||||||
|
)
|
||||||
|
logger.info(f"Created MeshMonitor source '{name}' -> {cfg.url}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown source type '{cfg.type}' for '{name}'")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create source '{name}': {e}")
|
||||||
|
|
||||||
|
def refresh_all(self) -> int:
|
||||||
|
"""Call maybe_refresh() on all sources.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of sources that refreshed
|
||||||
|
"""
|
||||||
|
refreshed = 0
|
||||||
|
for name, source in self._sources.items():
|
||||||
|
try:
|
||||||
|
if source.maybe_refresh():
|
||||||
|
refreshed += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error refreshing source '{name}': {e}")
|
||||||
|
return refreshed
|
||||||
|
|
||||||
|
def get_source(self, name: str) -> Optional[MeshviewSource | MeshMonitorDataSource]:
|
||||||
|
"""Get a specific source by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Source name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Source instance or None if not found
|
||||||
|
"""
|
||||||
|
return self._sources.get(name)
|
||||||
|
|
||||||
|
def get_all_nodes(self) -> list[dict]:
|
||||||
|
"""Get nodes from all sources, tagged with source name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of node dicts with '_source' field added
|
||||||
|
"""
|
||||||
|
all_nodes = []
|
||||||
|
for name, source in self._sources.items():
|
||||||
|
for node in source.nodes:
|
||||||
|
tagged = dict(node)
|
||||||
|
tagged["_source"] = name
|
||||||
|
all_nodes.append(tagged)
|
||||||
|
return all_nodes
|
||||||
|
|
||||||
|
def get_all_edges(self) -> list[dict]:
|
||||||
|
"""Get edges from all Meshview sources, tagged with source name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of edge dicts with '_source' field added
|
||||||
|
"""
|
||||||
|
all_edges = []
|
||||||
|
for name, source in self._sources.items():
|
||||||
|
if isinstance(source, MeshviewSource):
|
||||||
|
for edge in source.edges:
|
||||||
|
tagged = dict(edge)
|
||||||
|
tagged["_source"] = name
|
||||||
|
all_edges.append(tagged)
|
||||||
|
return all_edges
|
||||||
|
|
||||||
|
def get_all_telemetry(self) -> list[dict]:
|
||||||
|
"""Get telemetry from all MeshMonitor sources, tagged with source name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of telemetry dicts with '_source' field added
|
||||||
|
"""
|
||||||
|
all_telemetry = []
|
||||||
|
for name, source in self._sources.items():
|
||||||
|
if isinstance(source, MeshMonitorDataSource):
|
||||||
|
for item in source.telemetry:
|
||||||
|
tagged = dict(item)
|
||||||
|
tagged["_source"] = name
|
||||||
|
all_telemetry.append(tagged)
|
||||||
|
return all_telemetry
|
||||||
|
|
||||||
|
def get_all_traceroutes(self) -> list[dict]:
|
||||||
|
"""Get traceroutes from all MeshMonitor sources, tagged with source name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of traceroute dicts with '_source' field added
|
||||||
|
"""
|
||||||
|
all_traceroutes = []
|
||||||
|
for name, source in self._sources.items():
|
||||||
|
if isinstance(source, MeshMonitorDataSource):
|
||||||
|
for item in source.traceroutes:
|
||||||
|
tagged = dict(item)
|
||||||
|
tagged["_source"] = name
|
||||||
|
all_traceroutes.append(tagged)
|
||||||
|
return all_traceroutes
|
||||||
|
|
||||||
|
def get_all_channels(self) -> list[dict]:
|
||||||
|
"""Get channels from all MeshMonitor sources, tagged with source name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of channel dicts with '_source' field added
|
||||||
|
"""
|
||||||
|
all_channels = []
|
||||||
|
for name, source in self._sources.items():
|
||||||
|
if isinstance(source, MeshMonitorDataSource):
|
||||||
|
for item in source.channels:
|
||||||
|
tagged = dict(item)
|
||||||
|
tagged["_source"] = name
|
||||||
|
all_channels.append(tagged)
|
||||||
|
return all_channels
|
||||||
|
|
||||||
|
def get_status(self) -> list[dict]:
|
||||||
|
"""Get status of all sources for TUI display.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of status dicts with source info
|
||||||
|
"""
|
||||||
|
status_list = []
|
||||||
|
for name, source in self._sources.items():
|
||||||
|
status = {
|
||||||
|
"name": name,
|
||||||
|
"type": "meshview" if isinstance(source, MeshviewSource) else "meshmonitor",
|
||||||
|
"enabled": True,
|
||||||
|
"is_loaded": source.is_loaded,
|
||||||
|
"last_refresh": source.last_refresh,
|
||||||
|
"last_error": source.last_error,
|
||||||
|
"node_count": len(source.nodes),
|
||||||
|
}
|
||||||
|
|
||||||
|
if isinstance(source, MeshviewSource):
|
||||||
|
status["edge_count"] = len(source.edges)
|
||||||
|
elif isinstance(source, MeshMonitorDataSource):
|
||||||
|
status["telemetry_count"] = len(source.telemetry)
|
||||||
|
status["traceroute_count"] = len(source.traceroutes)
|
||||||
|
status["channel_count"] = len(source.channels)
|
||||||
|
|
||||||
|
status_list.append(status)
|
||||||
|
|
||||||
|
return status_list
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_count(self) -> int:
|
||||||
|
"""Get number of active sources."""
|
||||||
|
return len(self._sources)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_names(self) -> list[str]:
|
||||||
|
"""Get list of source names."""
|
||||||
|
return list(self._sources.keys())
|
||||||
682
meshai/router.py
682
meshai/router.py
|
|
@ -1,340 +1,342 @@
|
||||||
"""Message routing logic for MeshAI."""
|
"""Message routing logic for MeshAI."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .backends.base import LLMBackend
|
from .backends.base import LLMBackend
|
||||||
from .commands import CommandContext, CommandDispatcher
|
from .commands import CommandContext, CommandDispatcher
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .connector import MeshConnector, MeshMessage
|
from .connector import MeshConnector, MeshMessage
|
||||||
from .context import MeshContext
|
from .context import MeshContext
|
||||||
from .history import ConversationHistory
|
from .history import ConversationHistory
|
||||||
from .chunker import chunk_response, ContinuationState
|
from .chunker import chunk_response, ContinuationState
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class RouteType(Enum):
|
class RouteType(Enum):
|
||||||
"""Type of message routing."""
|
"""Type of message routing."""
|
||||||
|
|
||||||
IGNORE = auto() # Don't respond
|
IGNORE = auto() # Don't respond
|
||||||
COMMAND = auto() # Bang command
|
COMMAND = auto() # Bang command
|
||||||
LLM = auto() # Route to LLM
|
LLM = auto() # Route to LLM
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RouteResult:
|
class RouteResult:
|
||||||
"""Result of routing decision."""
|
"""Result of routing decision."""
|
||||||
|
|
||||||
route_type: RouteType
|
route_type: RouteType
|
||||||
response: Optional[str] = None # For commands, the response
|
response: Optional[str] = None # For commands, the response
|
||||||
query: Optional[str] = None # For LLM, the cleaned query
|
query: Optional[str] = None # For LLM, the cleaned query
|
||||||
|
|
||||||
|
|
||||||
# advBBS protocol and notification prefixes to ignore
|
# advBBS protocol and notification prefixes to ignore
|
||||||
ADVBBS_PREFIXES = (
|
ADVBBS_PREFIXES = (
|
||||||
"MAILREQ|", "MAILACK|", "MAILNAK|", "MAILDAT|", "MAILDLV|",
|
"MAILREQ|", "MAILACK|", "MAILNAK|", "MAILDAT|", "MAILDLV|",
|
||||||
"BOARDREQ|", "BOARDACK|", "BOARDNAK|", "BOARDDAT|", "BOARDDLV|",
|
"BOARDREQ|", "BOARDACK|", "BOARDNAK|", "BOARDDAT|", "BOARDDLV|",
|
||||||
"advBBS|",
|
"advBBS|",
|
||||||
"[MAIL]",
|
"[MAIL]",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Patterns that suggest prompt injection attempts
|
# Patterns that suggest prompt injection attempts
|
||||||
_INJECTION_PATTERNS = [
|
_INJECTION_PATTERNS = [
|
||||||
re.compile(r"ignore\s+(all\s+)?previous", re.IGNORECASE),
|
re.compile(r"ignore\s+(all\s+)?previous", re.IGNORECASE),
|
||||||
re.compile(r"ignore\s+your\s+instructions", re.IGNORECASE),
|
re.compile(r"ignore\s+your\s+instructions", re.IGNORECASE),
|
||||||
re.compile(r"disregard\s+(all\s+)?previous", re.IGNORECASE),
|
re.compile(r"disregard\s+(all\s+)?previous", re.IGNORECASE),
|
||||||
re.compile(r"you\s+are\s+now\b", re.IGNORECASE),
|
re.compile(r"you\s+are\s+now\b", re.IGNORECASE),
|
||||||
re.compile(r"new\s+instructions?\s*:", re.IGNORECASE),
|
re.compile(r"new\s+instructions?\s*:", re.IGNORECASE),
|
||||||
re.compile(r"system\s*prompt\s*:", re.IGNORECASE),
|
re.compile(r"system\s*prompt\s*:", re.IGNORECASE),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class MessageRouter:
|
class MessageRouter:
|
||||||
"""Routes incoming messages to appropriate handlers."""
|
"""Routes incoming messages to appropriate handlers."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: Config,
|
config: Config,
|
||||||
connector: MeshConnector,
|
connector: MeshConnector,
|
||||||
history: ConversationHistory,
|
history: ConversationHistory,
|
||||||
dispatcher: CommandDispatcher,
|
dispatcher: CommandDispatcher,
|
||||||
llm_backend: LLMBackend,
|
llm_backend: LLMBackend,
|
||||||
context: MeshContext = None,
|
context: MeshContext = None,
|
||||||
meshmonitor_sync=None,
|
meshmonitor_sync=None,
|
||||||
knowledge=None,
|
knowledge=None,
|
||||||
):
|
source_manager=None,
|
||||||
self.config = config
|
):
|
||||||
self.connector = connector
|
self.config = config
|
||||||
self.history = history
|
self.connector = connector
|
||||||
self.dispatcher = dispatcher
|
self.history = history
|
||||||
self.llm = llm_backend
|
self.dispatcher = dispatcher
|
||||||
self.context = context
|
self.llm = llm_backend
|
||||||
self.meshmonitor_sync = meshmonitor_sync
|
self.context = context
|
||||||
self.knowledge = knowledge
|
self.meshmonitor_sync = meshmonitor_sync
|
||||||
self.continuations = ContinuationState(max_continuations=3)
|
self.knowledge = knowledge
|
||||||
|
self.source_manager = source_manager # For future use in Phase 3
|
||||||
|
self.continuations = ContinuationState(max_continuations=3)
|
||||||
def should_respond(self, message: MeshMessage) -> bool:
|
|
||||||
"""Determine if we should respond to this message.
|
|
||||||
|
def should_respond(self, message: MeshMessage) -> bool:
|
||||||
DM-only bot: ignores all public channel messages.
|
"""Determine if we should respond to this message.
|
||||||
Commands and conversational LLM responses both work in DMs.
|
|
||||||
|
DM-only bot: ignores all public channel messages.
|
||||||
Args:
|
Commands and conversational LLM responses both work in DMs.
|
||||||
message: Incoming message
|
|
||||||
|
Args:
|
||||||
Returns:
|
message: Incoming message
|
||||||
True if we should process this message
|
|
||||||
"""
|
Returns:
|
||||||
# Always ignore our own messages
|
True if we should process this message
|
||||||
if message.sender_id == self.connector.my_node_id:
|
"""
|
||||||
return False
|
# Always ignore our own messages
|
||||||
|
if message.sender_id == self.connector.my_node_id:
|
||||||
# Only respond to DMs
|
return False
|
||||||
if not message.is_dm:
|
|
||||||
return False
|
# Only respond to DMs
|
||||||
|
if not message.is_dm:
|
||||||
if not self.config.bot.respond_to_dms:
|
return False
|
||||||
return False
|
|
||||||
|
if not self.config.bot.respond_to_dms:
|
||||||
# Ignore advBBS protocol and notification messages
|
return False
|
||||||
if self.config.bot.filter_bbs_protocols:
|
|
||||||
if any(message.text.startswith(p) for p in ADVBBS_PREFIXES):
|
# Ignore advBBS protocol and notification messages
|
||||||
logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...")
|
if self.config.bot.filter_bbs_protocols:
|
||||||
return False
|
if any(message.text.startswith(p) for p in ADVBBS_PREFIXES):
|
||||||
|
logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...")
|
||||||
# Ignore messages that MeshMonitor will handle
|
return False
|
||||||
if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text):
|
|
||||||
logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...")
|
# Ignore messages that MeshMonitor will handle
|
||||||
return False
|
if self.meshmonitor_sync and self.meshmonitor_sync.matches(message.text):
|
||||||
|
logger.debug(f"Ignoring MeshMonitor-handled message: {message.text[:40]}...")
|
||||||
return True
|
return False
|
||||||
|
|
||||||
def check_continuation(self, message) -> list[str] | None:
|
return True
|
||||||
"""Check if this is a continuation request and return messages if so.
|
|
||||||
|
def check_continuation(self, message) -> list[str] | None:
|
||||||
Returns:
|
"""Check if this is a continuation request and return messages if so.
|
||||||
List of messages to send, or None if not a continuation
|
|
||||||
"""
|
Returns:
|
||||||
user_id = message.sender_id
|
List of messages to send, or None if not a continuation
|
||||||
text = message.text.strip()
|
"""
|
||||||
|
user_id = message.sender_id
|
||||||
logger.info(f"check_continuation: user={user_id}, text='{text[:30]}', has_pending={self.continuations.has_pending(user_id)}")
|
text = message.text.strip()
|
||||||
|
|
||||||
if self.continuations.has_pending(user_id):
|
logger.info(f"check_continuation: user={user_id}, text='{text[:30]}', has_pending={self.continuations.has_pending(user_id)}")
|
||||||
if self.continuations.is_continuation_request(text):
|
|
||||||
result = self.continuations.get_continuation(user_id)
|
if self.continuations.has_pending(user_id):
|
||||||
if result:
|
if self.continuations.is_continuation_request(text):
|
||||||
messages, _ = result
|
result = self.continuations.get_continuation(user_id)
|
||||||
return messages
|
if result:
|
||||||
# Max continuations reached, return None to fall through
|
messages, _ = result
|
||||||
else:
|
return messages
|
||||||
# User asked something new, clear pending continuation
|
# Max continuations reached, return None to fall through
|
||||||
self.continuations.clear(user_id)
|
else:
|
||||||
|
# User asked something new, clear pending continuation
|
||||||
return None
|
self.continuations.clear(user_id)
|
||||||
|
|
||||||
async def route(self, message: MeshMessage) -> RouteResult:
|
return None
|
||||||
"""Route a message and generate response.
|
|
||||||
|
async def route(self, message: MeshMessage) -> RouteResult:
|
||||||
Args:
|
"""Route a message and generate response.
|
||||||
message: Incoming message to route
|
|
||||||
|
Args:
|
||||||
Returns:
|
message: Incoming message to route
|
||||||
RouteResult with routing decision and any response
|
|
||||||
"""
|
Returns:
|
||||||
text = message.text.strip()
|
RouteResult with routing decision and any response
|
||||||
|
"""
|
||||||
# Check for bang command first
|
text = message.text.strip()
|
||||||
if self.dispatcher.is_command(text):
|
|
||||||
context = self._make_command_context(message)
|
# Check for bang command first
|
||||||
response = await self.dispatcher.dispatch(text, context)
|
if self.dispatcher.is_command(text):
|
||||||
return RouteResult(RouteType.COMMAND, response=response)
|
context = self._make_command_context(message)
|
||||||
|
response = await self.dispatcher.dispatch(text, context)
|
||||||
# Clean up the message (remove @mention)
|
return RouteResult(RouteType.COMMAND, response=response)
|
||||||
query = self._clean_query(text)
|
|
||||||
|
# Clean up the message (remove @mention)
|
||||||
if not query:
|
query = self._clean_query(text)
|
||||||
return RouteResult(RouteType.IGNORE)
|
|
||||||
|
if not query:
|
||||||
# Route to LLM
|
return RouteResult(RouteType.IGNORE)
|
||||||
return RouteResult(RouteType.LLM, query=query)
|
|
||||||
|
# Route to LLM
|
||||||
async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
|
return RouteResult(RouteType.LLM, query=query)
|
||||||
"""Generate LLM response for a message.
|
|
||||||
|
async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
|
||||||
Args:
|
"""Generate LLM response for a message.
|
||||||
message: Original message
|
|
||||||
query: Cleaned query text
|
Args:
|
||||||
|
message: Original message
|
||||||
Returns:
|
query: Cleaned query text
|
||||||
Generated response
|
|
||||||
"""
|
Returns:
|
||||||
# Add user message to history
|
Generated response
|
||||||
await self.history.add_message(message.sender_id, "user", query)
|
"""
|
||||||
|
# Add user message to history
|
||||||
# Get conversation history
|
await self.history.add_message(message.sender_id, "user", query)
|
||||||
history = await self.history.get_history_for_llm(message.sender_id)
|
|
||||||
|
# Get conversation history
|
||||||
# Build system prompt in order: identity -> static -> meshmonitor -> context
|
history = await self.history.get_history_for_llm(message.sender_id)
|
||||||
|
|
||||||
# 1. Dynamic identity from bot config
|
# Build system prompt in order: identity -> static -> meshmonitor -> context
|
||||||
bot_name = self.config.bot.name or "MeshAI"
|
|
||||||
bot_owner = self.config.bot.owner or "Unknown"
|
# 1. Dynamic identity from bot config
|
||||||
|
bot_name = self.config.bot.name or "MeshAI"
|
||||||
identity = (
|
bot_owner = self.config.bot.owner or "Unknown"
|
||||||
f"You are {bot_name}, an LLM-powered conversational assistant running on a "
|
|
||||||
f"Meshtastic mesh network. Your managing operator is {bot_owner}. "
|
identity = (
|
||||||
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
|
f"You are {bot_name}, an LLM-powered conversational assistant running on a "
|
||||||
f"IDENTITY: Your name is {bot_name}. You respond to DMs only. You connect "
|
f"Meshtastic mesh network. Your managing operator is {bot_owner}. "
|
||||||
f"to a Meshtastic node via TCP through meshtasticd.\n\n"
|
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
|
||||||
)
|
f"IDENTITY: Your name is {bot_name}. You respond to DMs only. You connect "
|
||||||
|
f"to a Meshtastic node via TCP through meshtasticd.\n\n"
|
||||||
# 2. Static system prompt from config
|
)
|
||||||
static_prompt = ""
|
|
||||||
if getattr(self.config.llm, 'use_system_prompt', True):
|
# 2. Static system prompt from config
|
||||||
static_prompt = self.config.llm.system_prompt
|
static_prompt = ""
|
||||||
|
if getattr(self.config.llm, 'use_system_prompt', True):
|
||||||
system_prompt = identity + static_prompt
|
static_prompt = self.config.llm.system_prompt
|
||||||
|
|
||||||
# 3. MeshMonitor info (only when enabled)
|
system_prompt = identity + static_prompt
|
||||||
if (
|
|
||||||
self.meshmonitor_sync
|
# 3. MeshMonitor info (only when enabled)
|
||||||
and self.config.meshmonitor.enabled
|
if (
|
||||||
and self.config.meshmonitor.inject_into_prompt
|
self.meshmonitor_sync
|
||||||
):
|
and self.config.meshmonitor.enabled
|
||||||
meshmonitor_intro = (
|
and self.config.meshmonitor.inject_into_prompt
|
||||||
"\n\nMESHMONITOR: You run alongside MeshMonitor (by Yeraze) on the same "
|
):
|
||||||
"meshtasticd node. MeshMonitor handles web dashboard, maps, telemetry, "
|
meshmonitor_intro = (
|
||||||
"traceroutes, security scanning, and auto-responder commands. Its trigger "
|
"\n\nMESHMONITOR: You run alongside MeshMonitor (by Yeraze) on the same "
|
||||||
"commands are listed below — if someone asks what commands are available, "
|
"meshtasticd node. MeshMonitor handles web dashboard, maps, telemetry, "
|
||||||
"mention both yours and MeshMonitor's. If someone asks where to get "
|
"traceroutes, security scanning, and auto-responder commands. Its trigger "
|
||||||
"MeshMonitor, direct them to github.com/Yeraze/meshmonitor"
|
"commands are listed below — if someone asks what commands are available, "
|
||||||
)
|
"mention both yours and MeshMonitor's. If someone asks where to get "
|
||||||
system_prompt += meshmonitor_intro
|
"MeshMonitor, direct them to github.com/Yeraze/meshmonitor"
|
||||||
|
)
|
||||||
commands_summary = self.meshmonitor_sync.get_commands_summary()
|
system_prompt += meshmonitor_intro
|
||||||
if commands_summary:
|
|
||||||
system_prompt += "\n\n" + commands_summary
|
commands_summary = self.meshmonitor_sync.get_commands_summary()
|
||||||
|
if commands_summary:
|
||||||
# 4. Inject mesh context if available
|
system_prompt += "\n\n" + commands_summary
|
||||||
if self.context:
|
|
||||||
max_items = getattr(self.config.context, 'max_context_items', 20)
|
# 4. Inject mesh context if available
|
||||||
context_block = self.context.get_context_block(max_items=max_items)
|
if self.context:
|
||||||
if context_block:
|
max_items = getattr(self.config.context, 'max_context_items', 20)
|
||||||
system_prompt += (
|
context_block = self.context.get_context_block(max_items=max_items)
|
||||||
"\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n"
|
if context_block:
|
||||||
+ context_block
|
system_prompt += (
|
||||||
)
|
"\n\n--- Recent mesh traffic (for context only, not messages to you) ---\n"
|
||||||
else:
|
+ context_block
|
||||||
system_prompt += (
|
)
|
||||||
"\n\n[No recent mesh traffic observed yet.]"
|
else:
|
||||||
)
|
system_prompt += (
|
||||||
|
"\n\n[No recent mesh traffic observed yet.]"
|
||||||
|
)
|
||||||
|
|
||||||
# 5. Knowledge base retrieval
|
|
||||||
if self.knowledge and query:
|
|
||||||
results = self.knowledge.search(query)
|
# 5. Knowledge base retrieval
|
||||||
if results:
|
if self.knowledge and query:
|
||||||
chunks = "\n\n".join(
|
results = self.knowledge.search(query)
|
||||||
f"[{r['title']}]: {r['excerpt']}" for r in results
|
if results:
|
||||||
)
|
chunks = "\n\n".join(
|
||||||
system_prompt += (
|
f"[{r['title']}]: {r['excerpt']}" for r in results
|
||||||
"\n\nREFERENCE KNOWLEDGE - Answer using this information:\n"
|
)
|
||||||
+ chunks
|
system_prompt += (
|
||||||
)
|
"\n\nREFERENCE KNOWLEDGE - Answer using this information:\n"
|
||||||
|
+ chunks
|
||||||
# DEBUG: Log system prompt status
|
)
|
||||||
logger.warning(f"SYSTEM PROMPT LENGTH: {len(system_prompt)} chars")
|
|
||||||
logger.warning(f"HAS REFERENCE KNOWLEDGE: {'REFERENCE KNOWLEDGE' in system_prompt}")
|
# DEBUG: Log system prompt status
|
||||||
try:
|
logger.warning(f"SYSTEM PROMPT LENGTH: {len(system_prompt)} chars")
|
||||||
response = await self.llm.generate(
|
logger.warning(f"HAS REFERENCE KNOWLEDGE: {'REFERENCE KNOWLEDGE' in system_prompt}")
|
||||||
messages=history,
|
try:
|
||||||
system_prompt=system_prompt,
|
response = await self.llm.generate(
|
||||||
max_tokens=500,
|
messages=history,
|
||||||
)
|
system_prompt=system_prompt,
|
||||||
except asyncio.TimeoutError:
|
max_tokens=500,
|
||||||
logger.error("LLM request timed out")
|
)
|
||||||
response = "Sorry, request timed out. Try again."
|
except asyncio.TimeoutError:
|
||||||
except Exception as e:
|
logger.error("LLM request timed out")
|
||||||
logger.error(f"LLM generation error: {e}")
|
response = "Sorry, request timed out. Try again."
|
||||||
response = "Sorry, I encountered an error. Please try again."
|
except Exception as e:
|
||||||
|
logger.error(f"LLM generation error: {e}")
|
||||||
# Add assistant response to history
|
response = "Sorry, I encountered an error. Please try again."
|
||||||
await self.history.add_message(message.sender_id, "assistant", response)
|
|
||||||
|
# Add assistant response to history
|
||||||
# Persist summary if one was created/updated
|
await self.history.add_message(message.sender_id, "assistant", response)
|
||||||
await self._persist_summary(message.sender_id)
|
|
||||||
|
# Persist summary if one was created/updated
|
||||||
# Chunk the response with sentence awareness
|
await self._persist_summary(message.sender_id)
|
||||||
messages, remaining = chunk_response(
|
|
||||||
response,
|
# Chunk the response with sentence awareness
|
||||||
max_chars=self.config.response.max_length,
|
messages, remaining = chunk_response(
|
||||||
max_messages=self.config.response.max_messages,
|
response,
|
||||||
)
|
max_chars=self.config.response.max_length,
|
||||||
|
max_messages=self.config.response.max_messages,
|
||||||
# Store remaining content for continuation
|
)
|
||||||
if remaining:
|
|
||||||
logger.info(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining")
|
# Store remaining content for continuation
|
||||||
self.continuations.store(message.sender_id, remaining)
|
if remaining:
|
||||||
else:
|
logger.info(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining")
|
||||||
logger.info(f"No remaining content for {message.sender_id}")
|
self.continuations.store(message.sender_id, remaining)
|
||||||
|
else:
|
||||||
return messages
|
logger.info(f"No remaining content for {message.sender_id}")
|
||||||
|
|
||||||
async def _persist_summary(self, user_id: str) -> None:
|
return messages
|
||||||
"""Persist any cached summary to the database.
|
|
||||||
|
async def _persist_summary(self, user_id: str) -> None:
|
||||||
Args:
|
"""Persist any cached summary to the database.
|
||||||
user_id: User identifier
|
|
||||||
"""
|
Args:
|
||||||
memory = self.llm.get_memory()
|
user_id: User identifier
|
||||||
if not memory:
|
"""
|
||||||
return
|
memory = self.llm.get_memory()
|
||||||
|
if not memory:
|
||||||
summary = memory.get_cached_summary(user_id)
|
return
|
||||||
if summary:
|
|
||||||
await self.history.store_summary(
|
summary = memory.get_cached_summary(user_id)
|
||||||
user_id,
|
if summary:
|
||||||
summary.summary,
|
await self.history.store_summary(
|
||||||
summary.message_count,
|
user_id,
|
||||||
)
|
summary.summary,
|
||||||
logger.debug(f"Persisted summary for {user_id}")
|
summary.message_count,
|
||||||
|
)
|
||||||
def _clean_query(self, text: str) -> str:
|
logger.debug(f"Persisted summary for {user_id}")
|
||||||
"""Clean up query text and check for prompt injection."""
|
|
||||||
cleaned = " ".join(text.split())
|
def _clean_query(self, text: str) -> str:
|
||||||
cleaned = cleaned.strip()
|
"""Clean up query text and check for prompt injection."""
|
||||||
|
cleaned = " ".join(text.split())
|
||||||
# Check for prompt injection
|
cleaned = cleaned.strip()
|
||||||
for pattern in _INJECTION_PATTERNS:
|
|
||||||
if pattern.search(cleaned):
|
# Check for prompt injection
|
||||||
logger.warning(
|
for pattern in _INJECTION_PATTERNS:
|
||||||
f"Possible prompt injection detected: {cleaned[:80]}..."
|
if pattern.search(cleaned):
|
||||||
)
|
logger.warning(
|
||||||
match = pattern.search(cleaned)
|
f"Possible prompt injection detected: {cleaned[:80]}..."
|
||||||
cleaned = cleaned[:match.start()].strip()
|
)
|
||||||
if not cleaned:
|
match = pattern.search(cleaned)
|
||||||
cleaned = "Hello"
|
cleaned = cleaned[:match.start()].strip()
|
||||||
break
|
if not cleaned:
|
||||||
|
cleaned = "Hello"
|
||||||
return cleaned
|
break
|
||||||
|
|
||||||
def _make_command_context(self, message: MeshMessage) -> CommandContext:
|
return cleaned
|
||||||
"""Create command context from message."""
|
|
||||||
return CommandContext(
|
def _make_command_context(self, message: MeshMessage) -> CommandContext:
|
||||||
sender_id=message.sender_id,
|
"""Create command context from message."""
|
||||||
sender_name=message.sender_name,
|
return CommandContext(
|
||||||
channel=message.channel,
|
sender_id=message.sender_id,
|
||||||
is_dm=message.is_dm,
|
sender_name=message.sender_name,
|
||||||
position=message.sender_position,
|
channel=message.channel,
|
||||||
config=self.config,
|
is_dm=message.is_dm,
|
||||||
connector=self.connector,
|
position=message.sender_position,
|
||||||
history=self.history,
|
config=self.config,
|
||||||
)
|
connector=self.connector,
|
||||||
|
history=self.history,
|
||||||
|
)
|
||||||
|
|
|
||||||
1
meshai/sources/__init__.py
Normal file
1
meshai/sources/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Mesh data source connectors."""
|
||||||
257
meshai/sources/meshmonitor_data.py
Normal file
257
meshai/sources/meshmonitor_data.py
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
"""MeshMonitor API data source."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MeshMonitorDataSource:
|
||||||
|
"""Fetches mesh data from a MeshMonitor instance."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, api_token: str, refresh_interval: int = 300):
|
||||||
|
"""Initialize MeshMonitor data source.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Base URL of MeshMonitor instance (e.g., http://192.168.1.100:3333)
|
||||||
|
api_token: API token for authentication. Supports ${ENV_VAR} format.
|
||||||
|
refresh_interval: Seconds between refresh checks (default 5 minutes)
|
||||||
|
"""
|
||||||
|
self._url = url.rstrip("/")
|
||||||
|
self._api_token = self._resolve_token(api_token)
|
||||||
|
self._refresh_interval = refresh_interval
|
||||||
|
|
||||||
|
# Cached data
|
||||||
|
self._nodes: list[dict] = []
|
||||||
|
self._channels: list[dict] = []
|
||||||
|
self._telemetry: list[dict] = []
|
||||||
|
self._traceroutes: list[dict] = []
|
||||||
|
self._network_stats: Optional[dict] = None
|
||||||
|
self._topology: Optional[dict] = None
|
||||||
|
self._packets: list[dict] = []
|
||||||
|
self._solar: list[dict] = []
|
||||||
|
|
||||||
|
self._last_refresh: float = 0.0
|
||||||
|
self._last_error: Optional[str] = None
|
||||||
|
self._is_loaded: bool = False
|
||||||
|
|
||||||
|
def _resolve_token(self, token: str) -> str:
|
||||||
|
"""Resolve token, supporting ${ENV_VAR} format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: API token or env var reference
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Resolved token value
|
||||||
|
"""
|
||||||
|
if token.startswith("${") and token.endswith("}"):
|
||||||
|
env_var = token[2:-1]
|
||||||
|
return os.environ.get(env_var, "")
|
||||||
|
return token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nodes(self) -> list[dict]:
|
||||||
|
"""Get cached nodes list."""
|
||||||
|
return self._nodes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channels(self) -> list[dict]:
|
||||||
|
"""Get cached channels list."""
|
||||||
|
return self._channels
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telemetry(self) -> list[dict]:
|
||||||
|
"""Get cached telemetry list."""
|
||||||
|
return self._telemetry
|
||||||
|
|
||||||
|
@property
|
||||||
|
def traceroutes(self) -> list[dict]:
|
||||||
|
"""Get cached traceroutes list."""
|
||||||
|
return self._traceroutes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def network_stats(self) -> Optional[dict]:
|
||||||
|
"""Get cached network stats."""
|
||||||
|
return self._network_stats
|
||||||
|
|
||||||
|
@property
|
||||||
|
def topology(self) -> Optional[dict]:
|
||||||
|
"""Get cached topology."""
|
||||||
|
return self._topology
|
||||||
|
|
||||||
|
@property
|
||||||
|
def packets(self) -> list[dict]:
|
||||||
|
"""Get cached packets list."""
|
||||||
|
return self._packets
|
||||||
|
|
||||||
|
@property
|
||||||
|
def solar(self) -> list[dict]:
|
||||||
|
"""Get cached solar estimates list."""
|
||||||
|
return self._solar
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_refresh(self) -> float:
|
||||||
|
"""Get last refresh timestamp (epoch)."""
|
||||||
|
return self._last_refresh
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> Optional[str]:
|
||||||
|
"""Get last error message if any."""
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_loaded(self) -> bool:
|
||||||
|
"""Check if data has been successfully loaded."""
|
||||||
|
return self._is_loaded
|
||||||
|
|
||||||
|
def _fetch_json(self, endpoint: str) -> Optional[dict | list]:
|
||||||
|
"""Fetch JSON from an endpoint with Bearer auth.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint path (e.g., /api/v1/nodes)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON data or None on error
|
||||||
|
"""
|
||||||
|
url = f"{self._url}{endpoint}"
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": f"Bearer {self._api_token}",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
req = Request(url, headers=headers)
|
||||||
|
with urlopen(req, timeout=15) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
# MeshMonitor wraps responses in {"success": true, "data": [...]}
|
||||||
|
# Extract the actual data if wrapped
|
||||||
|
if isinstance(data, dict) and "data" in data:
|
||||||
|
return data["data"]
|
||||||
|
return data
|
||||||
|
|
||||||
|
except HTTPError as e:
|
||||||
|
logger.warning(f"MeshMonitor {endpoint}: HTTP {e.code} {e.reason}")
|
||||||
|
return None
|
||||||
|
except URLError as e:
|
||||||
|
logger.warning(f"MeshMonitor {endpoint}: Connection error - {e.reason}")
|
||||||
|
return None
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"MeshMonitor {endpoint}: Invalid JSON - {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"MeshMonitor {endpoint}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_all(self) -> bool:
|
||||||
|
"""Fetch all data from MeshMonitor API.
|
||||||
|
|
||||||
|
Fetches all endpoints independently. One failure doesn't block others.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at least one endpoint succeeded
|
||||||
|
"""
|
||||||
|
success_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Fetch nodes
|
||||||
|
data = self._fetch_json("/api/v1/nodes")
|
||||||
|
if data is not None:
|
||||||
|
self._nodes = data if isinstance(data, list) else []
|
||||||
|
success_count += 1
|
||||||
|
logger.debug(f"MeshMonitor: fetched {len(self._nodes)} nodes")
|
||||||
|
else:
|
||||||
|
errors.append("nodes")
|
||||||
|
|
||||||
|
# Fetch channels
|
||||||
|
data = self._fetch_json("/api/v1/channels")
|
||||||
|
if data is not None:
|
||||||
|
self._channels = data if isinstance(data, list) else []
|
||||||
|
success_count += 1
|
||||||
|
logger.debug(f"MeshMonitor: fetched {len(self._channels)} channels")
|
||||||
|
else:
|
||||||
|
errors.append("channels")
|
||||||
|
|
||||||
|
# Fetch telemetry
|
||||||
|
data = self._fetch_json("/api/v1/telemetry")
|
||||||
|
if data is not None:
|
||||||
|
self._telemetry = data if isinstance(data, list) else []
|
||||||
|
success_count += 1
|
||||||
|
logger.debug(f"MeshMonitor: fetched {len(self._telemetry)} telemetry records")
|
||||||
|
else:
|
||||||
|
errors.append("telemetry")
|
||||||
|
|
||||||
|
# Fetch traceroutes
|
||||||
|
data = self._fetch_json("/api/v1/traceroutes")
|
||||||
|
if data is not None:
|
||||||
|
self._traceroutes = data if isinstance(data, list) else []
|
||||||
|
success_count += 1
|
||||||
|
logger.debug(f"MeshMonitor: fetched {len(self._traceroutes)} traceroutes")
|
||||||
|
else:
|
||||||
|
errors.append("traceroutes")
|
||||||
|
|
||||||
|
# Fetch network stats
|
||||||
|
data = self._fetch_json("/api/v1/network")
|
||||||
|
if data is not None:
|
||||||
|
self._network_stats = data if isinstance(data, dict) else None
|
||||||
|
success_count += 1
|
||||||
|
logger.debug("MeshMonitor: fetched network stats")
|
||||||
|
else:
|
||||||
|
errors.append("network")
|
||||||
|
|
||||||
|
# Fetch topology
|
||||||
|
data = self._fetch_json("/api/v1/network/topology")
|
||||||
|
if data is not None:
|
||||||
|
self._topology = data if isinstance(data, dict) else None
|
||||||
|
success_count += 1
|
||||||
|
logger.debug("MeshMonitor: fetched topology")
|
||||||
|
else:
|
||||||
|
errors.append("topology")
|
||||||
|
|
||||||
|
# Fetch packets
|
||||||
|
data = self._fetch_json("/api/v1/packets")
|
||||||
|
if data is not None:
|
||||||
|
self._packets = data if isinstance(data, list) else []
|
||||||
|
success_count += 1
|
||||||
|
logger.debug(f"MeshMonitor: fetched {len(self._packets)} packets")
|
||||||
|
else:
|
||||||
|
errors.append("packets")
|
||||||
|
|
||||||
|
# Fetch solar estimates
|
||||||
|
data = self._fetch_json("/api/v1/solar")
|
||||||
|
if data is not None:
|
||||||
|
self._solar = data if isinstance(data, list) else []
|
||||||
|
success_count += 1
|
||||||
|
logger.debug(f"MeshMonitor: fetched {len(self._solar)} solar estimates")
|
||||||
|
else:
|
||||||
|
errors.append("solar")
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
self._last_refresh = time.time()
|
||||||
|
|
||||||
|
if success_count > 0:
|
||||||
|
self._is_loaded = True
|
||||||
|
self._last_error = None
|
||||||
|
logger.info(
|
||||||
|
f"MeshMonitor refresh: {len(self._nodes)} nodes, "
|
||||||
|
f"{len(self._telemetry)} telemetry, {len(self._traceroutes)} traceroutes"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self._last_error = f"All endpoints failed: {', '.join(errors)}"
|
||||||
|
logger.error(f"MeshMonitor: {self._last_error}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def maybe_refresh(self) -> bool:
|
||||||
|
"""Refresh data if interval has elapsed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if refresh was performed
|
||||||
|
"""
|
||||||
|
if time.time() - self._last_refresh >= self._refresh_interval:
|
||||||
|
return self.fetch_all()
|
||||||
|
return False
|
||||||
166
meshai/sources/meshview.py
Normal file
166
meshai/sources/meshview.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"""Meshview API data source."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MeshviewSource:
|
||||||
|
"""Fetches mesh data from a Meshview instance."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, refresh_interval: int = 300):
|
||||||
|
"""Initialize Meshview source.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Base URL of Meshview instance (e.g., https://meshview.example.com)
|
||||||
|
refresh_interval: Seconds between refresh checks (default 5 minutes)
|
||||||
|
"""
|
||||||
|
self._url = url.rstrip("/")
|
||||||
|
self._refresh_interval = refresh_interval
|
||||||
|
self._nodes: list[dict] = []
|
||||||
|
self._edges: list[dict] = []
|
||||||
|
self._stats: Optional[dict | list] = None
|
||||||
|
self._counts: Optional[dict] = None
|
||||||
|
self._last_refresh: float = 0.0
|
||||||
|
self._last_error: Optional[str] = None
|
||||||
|
self._is_loaded: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nodes(self) -> list[dict]:
|
||||||
|
"""Get cached nodes list."""
|
||||||
|
return self._nodes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def edges(self) -> list[dict]:
|
||||||
|
"""Get cached edges list."""
|
||||||
|
return self._edges
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> Optional[dict | list]:
|
||||||
|
"""Get cached stats."""
|
||||||
|
return self._stats
|
||||||
|
|
||||||
|
@property
|
||||||
|
def counts(self) -> Optional[dict]:
|
||||||
|
"""Get cached counts."""
|
||||||
|
return self._counts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_refresh(self) -> float:
|
||||||
|
"""Get last refresh timestamp (epoch)."""
|
||||||
|
return self._last_refresh
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> Optional[str]:
|
||||||
|
"""Get last error message if any."""
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_loaded(self) -> bool:
|
||||||
|
"""Check if data has been successfully loaded."""
|
||||||
|
return self._is_loaded
|
||||||
|
|
||||||
|
def _fetch_json(self, endpoint: str) -> Optional[dict | list]:
|
||||||
|
"""Fetch JSON from an endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API endpoint path (e.g., /api/nodes)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON data or None on error
|
||||||
|
"""
|
||||||
|
url = f"{self._url}{endpoint}"
|
||||||
|
try:
|
||||||
|
req = Request(url, headers={"Accept": "application/json"})
|
||||||
|
with urlopen(req, timeout=15) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except HTTPError as e:
|
||||||
|
logger.warning(f"Meshview {endpoint}: HTTP {e.code} {e.reason}")
|
||||||
|
return None
|
||||||
|
except URLError as e:
|
||||||
|
logger.warning(f"Meshview {endpoint}: Connection error - {e.reason}")
|
||||||
|
return None
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Meshview {endpoint}: Invalid JSON - {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Meshview {endpoint}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_all(self) -> bool:
|
||||||
|
"""Fetch all data from Meshview API.
|
||||||
|
|
||||||
|
Fetches nodes, edges, stats, and counts independently.
|
||||||
|
One failure doesn't block others.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at least one endpoint succeeded
|
||||||
|
"""
|
||||||
|
success_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Fetch nodes
|
||||||
|
data = self._fetch_json("/api/nodes")
|
||||||
|
if data is not None:
|
||||||
|
self._nodes = data if isinstance(data, list) else []
|
||||||
|
success_count += 1
|
||||||
|
logger.debug(f"Meshview: fetched {len(self._nodes)} nodes")
|
||||||
|
else:
|
||||||
|
errors.append("nodes")
|
||||||
|
|
||||||
|
# Fetch edges
|
||||||
|
data = self._fetch_json("/api/edges")
|
||||||
|
if data is not None:
|
||||||
|
self._edges = data if isinstance(data, list) else []
|
||||||
|
success_count += 1
|
||||||
|
logger.debug(f"Meshview: fetched {len(self._edges)} edges")
|
||||||
|
else:
|
||||||
|
errors.append("edges")
|
||||||
|
|
||||||
|
# Fetch stats (24h hourly)
|
||||||
|
data = self._fetch_json("/api/stats?period_type=hour&length=24")
|
||||||
|
if data is not None:
|
||||||
|
self._stats = data
|
||||||
|
success_count += 1
|
||||||
|
logger.debug("Meshview: fetched stats")
|
||||||
|
else:
|
||||||
|
errors.append("stats")
|
||||||
|
|
||||||
|
# Fetch counts
|
||||||
|
data = self._fetch_json("/api/stats/count")
|
||||||
|
if data is not None:
|
||||||
|
self._counts = data if isinstance(data, dict) else None
|
||||||
|
success_count += 1
|
||||||
|
logger.debug("Meshview: fetched counts")
|
||||||
|
else:
|
||||||
|
errors.append("counts")
|
||||||
|
|
||||||
|
# Update state
|
||||||
|
self._last_refresh = time.time()
|
||||||
|
|
||||||
|
if success_count > 0:
|
||||||
|
self._is_loaded = True
|
||||||
|
self._last_error = None
|
||||||
|
logger.info(
|
||||||
|
f"Meshview refresh: {len(self._nodes)} nodes, {len(self._edges)} edges"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self._last_error = f"All endpoints failed: {', '.join(errors)}"
|
||||||
|
logger.error(f"Meshview: {self._last_error}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def maybe_refresh(self) -> bool:
|
||||||
|
"""Refresh data if interval has elapsed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if refresh was performed
|
||||||
|
"""
|
||||||
|
if time.time() - self._last_refresh >= self._refresh_interval:
|
||||||
|
return self.fetch_all()
|
||||||
|
return False
|
||||||
Loading…
Add table
Add a link
Reference in a new issue