mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-22 15:44:39 +02:00
Initial commit: MeshAI - LLM-powered Meshtastic assistant
Features: - Multi-backend LLM support (OpenAI, Anthropic, Google) - Rolling summary memory for token optimization (~70-80% reduction) - Per-user conversation history with SQLite persistence - Bang commands (!help, !ping, !reset, !status, !weather) - Meshtastic integration via serial or TCP - Message chunking for mesh network constraints (150 char limit) - Rate limiting to prevent network congestion - Rich TUI configurator - Docker support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
fd3f995ebb
43 changed files with 7947 additions and 0 deletions
6
meshai/commands/__init__.py
Normal file
6
meshai/commands/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Bang commands for MeshAI."""
|
||||
|
||||
from .dispatcher import CommandDispatcher
|
||||
from .base import CommandHandler, CommandContext
|
||||
|
||||
__all__ = ["CommandDispatcher", "CommandHandler", "CommandContext"]
|
||||
72
meshai/commands/base.py
Normal file
72
meshai/commands/base.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Base classes for command handlers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
from ..connector import MeshConnector
|
||||
from ..history import ConversationHistory
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandContext:
|
||||
"""Context passed to command handlers."""
|
||||
|
||||
sender_id: str # Node ID of sender
|
||||
sender_name: str # Display name of sender
|
||||
channel: int # Channel message was received on
|
||||
is_dm: bool # True if direct message
|
||||
position: Optional[tuple[float, float]] # Sender's GPS position (lat, lon)
|
||||
|
||||
# References to shared resources
|
||||
config: "Config"
|
||||
connector: "MeshConnector"
|
||||
history: "ConversationHistory"
|
||||
|
||||
|
||||
class CommandHandler(ABC):
|
||||
"""Base class for bang command handlers."""
|
||||
|
||||
# Command name (without !)
|
||||
name: str = ""
|
||||
|
||||
# Brief description for !help
|
||||
description: str = ""
|
||||
|
||||
# Usage example
|
||||
usage: str = ""
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""Execute the command.
|
||||
|
||||
Args:
|
||||
args: Arguments passed after the command (may be empty)
|
||||
context: Command execution context
|
||||
|
||||
Returns:
|
||||
Response string to send back
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CommandResult:
|
||||
"""Result from command execution."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
response: str,
|
||||
success: bool = True,
|
||||
suppress_history: bool = True,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
response: Text response to send
|
||||
success: Whether command succeeded
|
||||
suppress_history: If True, don't add to conversation history
|
||||
"""
|
||||
self.response = response
|
||||
self.success = success
|
||||
self.suppress_history = suppress_history
|
||||
116
meshai/commands/dispatcher.py
Normal file
116
meshai/commands/dispatcher.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Command dispatcher for bang commands."""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommandDispatcher:
|
||||
"""Registry and dispatcher for bang commands."""
|
||||
|
||||
def __init__(self):
|
||||
self._commands: dict[str, CommandHandler] = {}
|
||||
|
||||
def register(self, handler: CommandHandler) -> None:
|
||||
"""Register a command handler.
|
||||
|
||||
Args:
|
||||
handler: CommandHandler instance to register
|
||||
"""
|
||||
name = handler.name.upper()
|
||||
self._commands[name] = handler
|
||||
logger.debug(f"Registered command: !{handler.name}")
|
||||
|
||||
def get_commands(self) -> list[CommandHandler]:
|
||||
"""Get all registered command handlers."""
|
||||
return list(self._commands.values())
|
||||
|
||||
def is_command(self, text: str) -> bool:
|
||||
"""Check if text is a bang command.
|
||||
|
||||
Args:
|
||||
text: Message text to check
|
||||
|
||||
Returns:
|
||||
True if text starts with !
|
||||
"""
|
||||
return text.strip().startswith("!")
|
||||
|
||||
def parse(self, text: str) -> tuple[Optional[str], str]:
|
||||
"""Parse command and arguments from text.
|
||||
|
||||
Args:
|
||||
text: Message text starting with !
|
||||
|
||||
Returns:
|
||||
Tuple of (command_name, arguments) or (None, "") if invalid
|
||||
"""
|
||||
text = text.strip()
|
||||
if not text.startswith("!"):
|
||||
return None, ""
|
||||
|
||||
# Remove ! prefix
|
||||
text = text[1:]
|
||||
|
||||
# Split into command and args
|
||||
parts = text.split(maxsplit=1)
|
||||
if not parts:
|
||||
return None, ""
|
||||
|
||||
cmd = parts[0].upper()
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
return cmd, args
|
||||
|
||||
async def dispatch(self, text: str, context: CommandContext) -> Optional[str]:
|
||||
"""Dispatch a command and return response.
|
||||
|
||||
Args:
|
||||
text: Message text (must start with !)
|
||||
context: Command execution context
|
||||
|
||||
Returns:
|
||||
Response string, or None if command not found
|
||||
"""
|
||||
cmd, args = self.parse(text)
|
||||
|
||||
if cmd is None:
|
||||
return None
|
||||
|
||||
handler = self._commands.get(cmd)
|
||||
|
||||
if handler is None:
|
||||
# Unknown command
|
||||
return f"Unknown command: !{cmd.lower()}. Try !help"
|
||||
|
||||
try:
|
||||
logger.debug(f"Dispatching !{cmd.lower()} from {context.sender_id}")
|
||||
response = await handler.execute(args, context)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing !{cmd.lower()}: {e}")
|
||||
return f"Error: {str(e)[:100]}"
|
||||
|
||||
|
||||
def create_dispatcher() -> CommandDispatcher:
|
||||
"""Create and populate command dispatcher with default commands."""
|
||||
from .help import HelpCommand
|
||||
from .ping import PingCommand
|
||||
from .reset import ResetCommand
|
||||
from .status import StatusCommand
|
||||
from .weather import WeatherCommand
|
||||
|
||||
dispatcher = CommandDispatcher()
|
||||
|
||||
# Register all commands
|
||||
dispatcher.register(HelpCommand(dispatcher))
|
||||
dispatcher.register(PingCommand())
|
||||
dispatcher.register(ResetCommand())
|
||||
dispatcher.register(StatusCommand())
|
||||
dispatcher.register(WeatherCommand())
|
||||
|
||||
return dispatcher
|
||||
25
meshai/commands/help.py
Normal file
25
meshai/commands/help.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Help command handler."""
|
||||
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
|
||||
class HelpCommand(CommandHandler):
|
||||
"""Display available commands."""
|
||||
|
||||
name = "help"
|
||||
description = "Show available commands"
|
||||
usage = "!help"
|
||||
|
||||
def __init__(self, dispatcher):
|
||||
self._dispatcher = dispatcher
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""List all available commands."""
|
||||
commands = self._dispatcher.get_commands()
|
||||
|
||||
# Build compact help text
|
||||
lines = ["Commands:"]
|
||||
for cmd in sorted(commands, key=lambda c: c.name):
|
||||
lines.append(f"!{cmd.name} - {cmd.description}")
|
||||
|
||||
return " | ".join(lines)
|
||||
15
meshai/commands/ping.py
Normal file
15
meshai/commands/ping.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Ping command handler."""
|
||||
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
|
||||
class PingCommand(CommandHandler):
|
||||
"""Simple connectivity test."""
|
||||
|
||||
name = "ping"
|
||||
description = "Test connectivity"
|
||||
usage = "!ping"
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""Respond with pong."""
|
||||
return "pong"
|
||||
23
meshai/commands/reset.py
Normal file
23
meshai/commands/reset.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Reset command handler."""
|
||||
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
|
||||
class ResetCommand(CommandHandler):
|
||||
"""Clear conversation history and summary."""
|
||||
|
||||
name = "reset"
|
||||
description = "Clear your chat history"
|
||||
usage = "!reset"
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""Clear conversation history and summary for the sender."""
|
||||
deleted = await context.history.clear_history(context.sender_id)
|
||||
|
||||
# Also clear the conversation summary
|
||||
await context.history.clear_summary(context.sender_id)
|
||||
|
||||
if deleted > 0:
|
||||
return f"Cleared {deleted} messages from history"
|
||||
else:
|
||||
return "No history to clear"
|
||||
43
meshai/commands/status.py
Normal file
43
meshai/commands/status.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Status command handler."""
|
||||
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from .. import __version__
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
# Track bot start time
|
||||
_start_time: float = time.time()
|
||||
|
||||
|
||||
def set_start_time(t: float) -> None:
|
||||
"""Set bot start time (called from main)."""
|
||||
global _start_time
|
||||
_start_time = t
|
||||
|
||||
|
||||
class StatusCommand(CommandHandler):
|
||||
"""Show bot status information."""
|
||||
|
||||
name = "status"
|
||||
description = "Show bot status"
|
||||
usage = "!status"
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""Return bot status information."""
|
||||
# Calculate uptime
|
||||
uptime_seconds = int(time.time() - _start_time)
|
||||
uptime = str(timedelta(seconds=uptime_seconds))
|
||||
|
||||
# Get history stats
|
||||
stats = await context.history.get_stats()
|
||||
|
||||
# Build status message
|
||||
parts = [
|
||||
f"MeshAI v{__version__}",
|
||||
f"Up: {uptime}",
|
||||
f"Users: {stats['unique_users']}",
|
||||
f"Msgs: {stats['total_messages']}",
|
||||
]
|
||||
|
||||
return " | ".join(parts)
|
||||
220
meshai/commands/weather.py
Normal file
220
meshai/commands/weather.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""Weather command handler."""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import CommandContext, CommandHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WeatherCommand(CommandHandler):
|
||||
"""Get weather information."""
|
||||
|
||||
name = "weather"
|
||||
description = "Get weather info"
|
||||
usage = "!weather [location]"
|
||||
|
||||
async def execute(self, args: str, context: CommandContext) -> str:
|
||||
"""Get weather for location or sender's GPS position."""
|
||||
config = context.config.weather
|
||||
|
||||
# Determine location
|
||||
location = await self._resolve_location(args.strip(), context)
|
||||
|
||||
if location is None:
|
||||
return "No location available. Use !weather <city> or enable GPS on your node."
|
||||
|
||||
# Try primary provider
|
||||
result = await self._fetch_weather(config.primary, location, context)
|
||||
|
||||
if result is None and config.fallback and config.fallback != "none":
|
||||
# Try fallback
|
||||
logger.debug(f"Primary weather provider failed, trying fallback: {config.fallback}")
|
||||
result = await self._fetch_weather(config.fallback, location, context)
|
||||
|
||||
if result is None:
|
||||
return "Weather lookup failed. Try again later."
|
||||
|
||||
return result
|
||||
|
||||
async def _resolve_location(
|
||||
self, args: str, context: CommandContext
|
||||
) -> Optional[str | tuple[float, float]]:
|
||||
"""Resolve location from args, GPS, or config default.
|
||||
|
||||
Returns:
|
||||
Location string, (lat, lon) tuple, or None
|
||||
"""
|
||||
# 1. If location provided in args, use it
|
||||
if args:
|
||||
return args
|
||||
|
||||
# 2. Try sender's GPS position
|
||||
if context.position:
|
||||
return context.position
|
||||
|
||||
# 3. Fall back to config default
|
||||
default = context.config.weather.default_location
|
||||
if default:
|
||||
return default
|
||||
|
||||
return None
|
||||
|
||||
async def _fetch_weather(
|
||||
self,
|
||||
provider: str,
|
||||
location: str | tuple[float, float],
|
||||
context: CommandContext,
|
||||
) -> Optional[str]:
|
||||
"""Fetch weather from specified provider."""
|
||||
try:
|
||||
if provider == "openmeteo":
|
||||
return await self._fetch_openmeteo(location, context)
|
||||
elif provider == "wttr":
|
||||
return await self._fetch_wttr(location, context)
|
||||
elif provider == "llm":
|
||||
return await self._fetch_llm(location, context)
|
||||
else:
|
||||
logger.warning(f"Unknown weather provider: {provider}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Weather fetch error ({provider}): {e}")
|
||||
return None
|
||||
|
||||
async def _fetch_openmeteo(
|
||||
self,
|
||||
location: str | tuple[float, float],
|
||||
context: CommandContext,
|
||||
) -> Optional[str]:
|
||||
"""Fetch weather from Open-Meteo API."""
|
||||
base_url = context.config.weather.openmeteo.url
|
||||
|
||||
# Get coordinates
|
||||
if isinstance(location, tuple):
|
||||
lat, lon = location
|
||||
else:
|
||||
# Geocode the location name
|
||||
coords = await self._geocode(location)
|
||||
if coords is None:
|
||||
return None
|
||||
lat, lon = coords
|
||||
|
||||
# Fetch current weather
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{base_url}/forecast",
|
||||
params={
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"current": "temperature_2m,weathercode,windspeed_10m",
|
||||
"temperature_unit": "fahrenheit",
|
||||
"windspeed_unit": "mph",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
current = data.get("current", {})
|
||||
temp = current.get("temperature_2m")
|
||||
code = current.get("weathercode", 0)
|
||||
wind = current.get("windspeed_10m")
|
||||
|
||||
if temp is None:
|
||||
return None
|
||||
|
||||
# Convert weather code to description
|
||||
condition = self._weather_code_to_text(code)
|
||||
|
||||
# Format location name
|
||||
loc_name = location if isinstance(location, str) else f"{lat:.2f},{lon:.2f}"
|
||||
|
||||
return f"{loc_name}: {temp:.0f}F, {condition}, Wind {wind:.0f}mph"
|
||||
|
||||
async def _fetch_wttr(
|
||||
self,
|
||||
location: str | tuple[float, float],
|
||||
context: CommandContext,
|
||||
) -> Optional[str]:
|
||||
"""Fetch weather from wttr.in."""
|
||||
base_url = context.config.weather.wttr.url
|
||||
|
||||
# Format location for wttr.in
|
||||
if isinstance(location, tuple):
|
||||
lat, lon = location
|
||||
loc_param = f"{lat},{lon}"
|
||||
else:
|
||||
loc_param = location.replace(" ", "+")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{base_url}/{loc_param}",
|
||||
params={"format": "%l:+%t,+%C,+Wind+%w"},
|
||||
headers={"User-Agent": "MeshAI/1.0"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return response.text.strip()
|
||||
|
||||
async def _fetch_llm(
|
||||
self,
|
||||
location: str | tuple[float, float],
|
||||
context: CommandContext,
|
||||
) -> Optional[str]:
|
||||
"""Let LLM fetch weather via web search.
|
||||
|
||||
This is a placeholder - actual implementation would route
|
||||
to the LLM backend with a weather query.
|
||||
"""
|
||||
# For now, return None to indicate this provider isn't fully implemented
|
||||
# The router will handle LLM queries separately
|
||||
logger.debug("LLM weather provider not yet integrated")
|
||||
return None
|
||||
|
||||
async def _geocode(self, location: str) -> Optional[tuple[float, float]]:
|
||||
"""Geocode a location name to coordinates using Open-Meteo geocoding."""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
"https://geocoding-api.open-meteo.com/v1/search",
|
||||
params={"name": location, "count": 1},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
return None
|
||||
|
||||
return (results[0]["latitude"], results[0]["longitude"])
|
||||
|
||||
def _weather_code_to_text(self, code: int) -> str:
|
||||
"""Convert WMO weather code to text description."""
|
||||
codes = {
|
||||
0: "Clear",
|
||||
1: "Mostly Clear",
|
||||
2: "Partly Cloudy",
|
||||
3: "Cloudy",
|
||||
45: "Foggy",
|
||||
48: "Fog",
|
||||
51: "Light Drizzle",
|
||||
53: "Drizzle",
|
||||
55: "Heavy Drizzle",
|
||||
61: "Light Rain",
|
||||
63: "Rain",
|
||||
65: "Heavy Rain",
|
||||
71: "Light Snow",
|
||||
73: "Snow",
|
||||
75: "Heavy Snow",
|
||||
77: "Snow Grains",
|
||||
80: "Light Showers",
|
||||
81: "Showers",
|
||||
82: "Heavy Showers",
|
||||
85: "Light Snow Showers",
|
||||
86: "Snow Showers",
|
||||
95: "Thunderstorm",
|
||||
96: "Thunderstorm w/ Hail",
|
||||
99: "Severe Thunderstorm",
|
||||
}
|
||||
return codes.get(code, "Unknown")
|
||||
Loading…
Add table
Add a link
Reference in a new issue