diff --git a/config.example.yaml b/config.example.yaml index 5da9aa1..07a71d8 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -66,3 +66,9 @@ weather: primary: openmeteo # openmeteo | wttr | llm fallback: llm # openmeteo | wttr | llm | none default_location: "" # Default location for !weather (optional) + +# === MESHMONITOR INTEGRATION === +meshmonitor: + enabled: false + triggers_file: /data/triggers.json + inject_into_prompt: true diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index fcfd7c8..467232a 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -61,10 +61,20 @@ llm: Keep responses VERY brief - under 250 characters total. Be concise but friendly. No markdown formatting. google_grounding: false + +meshmonitor: + enabled: false + triggers_file: /data/triggers.json + inject_into_prompt: true EOF echo "Default config created. Configure via http://localhost:7682" fi +# Create triggers.json if missing +if [ ! -f "/data/triggers.json" ]; then + echo '{"triggers": []}' > /data/triggers.json +fi + # Start ttyd for web-based config access echo "Starting web config interface on port 7682..." ttyd -W -p 7682 \ diff --git a/meshai/config.py b/meshai/config.py index a51841b..3b1466b 100644 --- a/meshai/config.py +++ b/meshai/config.py @@ -75,6 +75,16 @@ class ContextConfig: max_context_items: int = 20 # Max observations injected into LLM context + + +@dataclass +class MeshMonitorConfig: + """MeshMonitor trigger sync settings.""" + + enabled: bool = False + triggers_file: str = "" + inject_into_prompt: bool = True + @dataclass class CommandsConfig: """Command settings.""" @@ -146,6 +156,7 @@ class Config: commands: CommandsConfig = field(default_factory=CommandsConfig) llm: LLMConfig = field(default_factory=LLMConfig) weather: WeatherConfig = field(default_factory=WeatherConfig) + meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig) _config_path: Optional[Path] = field(default=None, repr=False) diff --git a/meshai/main.py b/meshai/main.py index 69b7773..367329b 100644 --- a/meshai/main.py +++ b/meshai/main.py @@ -37,6 +37,7 @@ class MeshAI: self.dispatcher: Optional[CommandDispatcher] = None self.llm: Optional[LLMBackend] = None self.context: Optional[MeshContext] = None + self.meshmonitor_sync = None self.router: Optional[MessageRouter] = None self.responder: Optional[Responder] = None self._running = False @@ -79,6 +80,10 @@ class MeshAI: self.context.prune() self._last_cleanup = time.time() + # Refresh MeshMonitor triggers if file changed + if self.meshmonitor_sync: + self.meshmonitor_sync.maybe_refresh() + async def stop(self) -> None: """Stop the bot.""" logger.info("Stopping MeshAI...") @@ -157,10 +162,21 @@ class MeshAI: else: self.context = None + # MeshMonitor trigger sync + self.meshmonitor_sync = None + mm_cfg = self.config.meshmonitor + if mm_cfg.enabled and mm_cfg.triggers_file: + from .meshmonitor import MeshMonitorSync + self.meshmonitor_sync = MeshMonitorSync( + triggers_file=mm_cfg.triggers_file, + ) + self.meshmonitor_sync.load() + # Message router self.router = MessageRouter( self.config, self.connector, self.history, self.dispatcher, self.llm, context=self.context, + meshmonitor_sync=self.meshmonitor_sync, ) # Responder diff --git a/meshai/meshmonitor.py b/meshai/meshmonitor.py new file mode 100644 index 0000000..f205f27 --- /dev/null +++ b/meshai/meshmonitor.py @@ -0,0 +1,179 @@ +"""Dynamic MeshMonitor trigger sync for MeshAI. + +Reads MeshMonitor auto-responder triggers from a JSON file and converts +them to compiled regex patterns. The router uses these patterns to ignore +messages that MeshMonitor will handle. + +Trigger file format (triggers.json): + {"triggers": ["weather {location}", "trivia", "ask {question}"]} + +Or simple list: + ["weather {location}", "trivia", "ask {question}"] +""" + +import json +import logging +import re +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +def _trigger_to_regex(trigger: str) -> re.Pattern: + """Convert a MeshMonitor trigger pattern to a compiled regex. + + MeshMonitor trigger format: + - "weather {location}" -> matches "weather miami" + - "w {city},{state}" -> matches "w parkland,fl" + - "trivia" -> matches "trivia" exactly + - "ask {question}" -> matches "ask what is mesh" + - "{name:regex}" -> custom regex per param + + Args: + trigger: MeshMonitor trigger pattern string + + Returns: + Compiled regex pattern (case insensitive) + """ + pattern = trigger.strip() + + # Temporarily replace {param} and {param:regex} blocks + param_blocks = [] + def _save_param(m): + param_blocks.append(m.group(0)) + return f"__PARAM_{len(param_blocks) - 1}__" + + pattern = re.sub(r"\{[^}]+\}", _save_param, pattern) + + # Escape remaining special chars + pattern = re.escape(pattern) + + # Restore param blocks as regex groups + for i, block in enumerate(param_blocks): + placeholder = re.escape(f"__PARAM_{i}__") + # Check for custom regex: {name:regex} + custom_match = re.match(r"\{(\w+):(.+)\}", block) + if custom_match: + _name, custom_regex = custom_match.groups() + replacement = f"({custom_regex})" + else: + # Default: match one or more characters + replacement = r"(.+)" + pattern = pattern.replace(placeholder, replacement) + + return re.compile(f"^{pattern}$", re.IGNORECASE) + + +class MeshMonitorSync: + """Syncs MeshMonitor auto-responder triggers for MeshAI to ignore. + + Reads trigger patterns from a JSON file and compiles them to regex. + Watches the file mtime so edits are picked up without restart. + """ + + def __init__(self, triggers_file: str): + self._triggers_file = Path(triggers_file) + self._patterns: list[re.Pattern] = [] + self._raw_triggers: list[str] = [] + self._file_mtime: float = 0.0 + + @property + def trigger_list(self) -> list[str]: + """Get raw trigger strings (for system prompt injection).""" + return self._raw_triggers + + def load(self) -> int: + """Load triggers from JSON file. + + Returns: + Number of trigger patterns loaded + """ + if not self._triggers_file.exists(): + logger.warning(f"Triggers file not found: {self._triggers_file}") + return 0 + + try: + self._file_mtime = self._triggers_file.stat().st_mtime + + with open(self._triggers_file) as f: + data = json.load(f) + + if isinstance(data, list): + raw_triggers = data + elif isinstance(data, dict): + raw_triggers = data.get("triggers", []) + else: + logger.warning(f"Unexpected triggers file format: {type(data)}") + return 0 + + self._raw_triggers = raw_triggers + self._compile_patterns(raw_triggers) + + logger.info( + f"Loaded {len(self._patterns)} MeshMonitor trigger patterns " + f"from {self._triggers_file}" + ) + return len(self._patterns) + + except Exception as e: + logger.error(f"Failed to load triggers file: {e}") + return 0 + + def maybe_refresh(self) -> bool: + """Reload triggers if the file has changed on disk. + + Returns: + True if triggers were refreshed + """ + if not self._triggers_file.exists(): + return False + + mtime = self._triggers_file.stat().st_mtime + if mtime > self._file_mtime: + self.load() + return True + + return False + + def matches(self, text: str) -> bool: + """Check if text matches any MeshMonitor trigger pattern. + + Args: + text: Incoming message text (stripped) + + Returns: + True if the message matches a MeshMonitor trigger + """ + for pattern in self._patterns: + if pattern.match(text): + logger.debug( + f"Message matches MeshMonitor trigger: " + f"{text[:40]}... -> {pattern.pattern}" + ) + return True + return False + + def _compile_patterns(self, raw_triggers: list[str]) -> None: + """Compile trigger strings into regex patterns. + + Handles comma-separated multi-patterns per trigger. + """ + patterns = [] + + for trigger in raw_triggers: + # Split multi-pattern triggers on comma + sub_patterns = [t.strip() for t in trigger.split(",")] + + for sub in sub_patterns: + if not sub: + continue + try: + compiled = _trigger_to_regex(sub) + patterns.append(compiled) + except Exception as e: + logger.warning( + f"Failed to compile trigger pattern: {e}" + ) + + self._patterns = patterns diff --git a/meshai/router.py b/meshai/router.py index 324f74e..09f47b3 100644 --- a/meshai/router.py +++ b/meshai/router.py @@ -64,6 +64,7 @@ class MessageRouter: dispatcher: CommandDispatcher, llm_backend: LLMBackend, context: MeshContext = None, + meshmonitor_sync=None, ): self.config = config self.connector = connector @@ -71,6 +72,7 @@ class MessageRouter: self.dispatcher = dispatcher self.llm = llm_backend self.context = context + self.meshmonitor_sync = meshmonitor_sync def should_respond(self, message: MeshMessage) -> bool: @@ -102,6 +104,15 @@ class MessageRouter: logger.debug(f"Ignoring advBBS message from {message.sender_id}: {message.text[:40]}...") return False + # Ignore messages that match MeshMonitor auto-responder triggers + if self.meshmonitor_sync and message.text: + if self.meshmonitor_sync.matches(message.text.strip()): + logger.debug( + f"Ignoring DM from {message.sender_id}: " + f"matches MeshMonitor trigger" + ) + return False + return True async def route(self, message: MeshMessage) -> RouteResult: @@ -165,6 +176,23 @@ class MessageRouter: "\n\n[No recent mesh traffic observed yet.]" ) + # Inject MeshMonitor commands into prompt + if ( + self.meshmonitor_sync + and getattr(self.config.meshmonitor, "inject_into_prompt", False) + and self.meshmonitor_sync.trigger_list + ): + trigger_lines = ", ".join( + t for t in self.meshmonitor_sync.trigger_list + if t not in ("commands", "command") + ) + system_prompt += ( + "\n\nMESHMONITOR COMMANDS (handled by MeshMonitor, not you): " + + trigger_lines + + "\nIf someone asks what commands are available, mention these too." + ) + + try: response = await self.llm.generate( messages=history, diff --git a/triggers.json b/triggers.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/triggers.json @@ -0,0 +1 @@ +{}