mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
- 2a: SafetyFilter + UserFilter — check user access before processing, filter LLM responses through SafetyFilter before sending - 2b: RateLimiter — check rate limits before processing, record messages after successful response delivery - 2c: PersonalityManager — pass to MessageRouter, used for system prompt generation instead of raw config.llm.system_prompt - 2d: WebhookClient — start/stop in lifecycle, fire events on message_received, response_sent, error, startup, shutdown - 2e: WebStatusServer — start/stop in lifecycle, record messages, responses, and errors in StatusData - 2f: AnnouncementScheduler — start/stop in lifecycle, uses connector.send_message as callback - 2g: FallbackBackend — wrap primary backend when config.llm.fallback is configured, otherwise use primary directly - 2h: CommandDispatcher — pass prefix, disabled_commands, and custom_commands from config to create_dispatcher() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
438 lines
14 KiB
Python
438 lines
14 KiB
Python
"""Main entry point for MeshAI."""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from . import __version__
|
|
from .announcements import AnnouncementScheduler
|
|
from .backends import AnthropicBackend, FallbackBackend, GoogleBackend, LLMBackend, OpenAIBackend
|
|
from .cli import run_configurator
|
|
from .commands import CommandDispatcher
|
|
from .commands.dispatcher import create_dispatcher
|
|
from .commands.status import set_start_time
|
|
from .config import Config, load_config
|
|
from .connector import MeshConnector, MeshMessage
|
|
from .history import ConversationHistory
|
|
from .memory import ConversationSummary
|
|
from .personality import PersonalityManager
|
|
from .rate_limiter import RateLimiter
|
|
from .responder import Responder
|
|
from .router import MessageRouter, RouteType
|
|
from .safety import SafetyFilter, UserFilter
|
|
from .web_status import WebStatusServer, get_status_data
|
|
from .webhook import WebhookClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MeshAI:
|
|
"""Main application class."""
|
|
|
|
def __init__(self, config: Config):
|
|
self.config = config
|
|
self.connector: Optional[MeshConnector] = None
|
|
self.history: Optional[ConversationHistory] = None
|
|
self.dispatcher: Optional[CommandDispatcher] = None
|
|
self.llm: Optional[LLMBackend] = None
|
|
self.router: Optional[MessageRouter] = None
|
|
self.responder: Optional[Responder] = None
|
|
self.personality: Optional[PersonalityManager] = None
|
|
self.safety_filter: Optional[SafetyFilter] = None
|
|
self.user_filter: Optional[UserFilter] = None
|
|
self.rate_limiter: Optional[RateLimiter] = None
|
|
self.webhook: Optional[WebhookClient] = None
|
|
self.web_status: Optional[WebStatusServer] = None
|
|
self.announcements: Optional[AnnouncementScheduler] = None
|
|
self._running = False
|
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
self._last_cleanup: float = 0.0
|
|
|
|
async def start(self) -> None:
|
|
"""Start the bot."""
|
|
logger.info(f"Starting MeshAI v{__version__}")
|
|
set_start_time(time.time())
|
|
|
|
# Initialize components
|
|
await self._init_components()
|
|
|
|
# Connect to Meshtastic
|
|
self.connector.connect()
|
|
self.connector.set_message_callback(self._on_message, asyncio.get_event_loop())
|
|
|
|
self._running = True
|
|
self._loop = asyncio.get_event_loop()
|
|
self._last_cleanup = time.time()
|
|
|
|
# Start async services
|
|
await self.webhook.start()
|
|
await self.webhook.on_startup()
|
|
await self.announcements.start()
|
|
|
|
# Start sync services
|
|
self.web_status.start()
|
|
|
|
# Write PID file
|
|
self._write_pid()
|
|
|
|
logger.info("MeshAI started successfully")
|
|
|
|
# Keep running
|
|
while self._running:
|
|
await asyncio.sleep(1)
|
|
|
|
# Periodic cleanup
|
|
if time.time() - self._last_cleanup >= 3600:
|
|
await self.history.cleanup_expired()
|
|
self._last_cleanup = time.time()
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the bot."""
|
|
logger.info("Stopping MeshAI...")
|
|
self._running = False
|
|
|
|
if self.webhook:
|
|
await self.webhook.on_shutdown()
|
|
await self.webhook.stop()
|
|
|
|
if self.announcements:
|
|
await self.announcements.stop()
|
|
|
|
if self.web_status:
|
|
self.web_status.stop()
|
|
|
|
if self.connector:
|
|
self.connector.disconnect()
|
|
|
|
if self.history:
|
|
await self.history.close()
|
|
|
|
if self.llm:
|
|
await self.llm.close()
|
|
|
|
self._remove_pid()
|
|
logger.info("MeshAI stopped")
|
|
|
|
async def _init_components(self) -> None:
|
|
"""Initialize all components."""
|
|
# Conversation history
|
|
self.history = ConversationHistory(self.config.history)
|
|
await self.history.initialize()
|
|
|
|
# Command dispatcher (2h: pass config)
|
|
self.dispatcher = create_dispatcher(
|
|
prefix=self.config.commands.prefix,
|
|
disabled_commands=self.config.commands.disabled_commands,
|
|
custom_commands=self.config.commands.custom_commands,
|
|
)
|
|
|
|
# Safety and user filters (2a)
|
|
self.user_filter = UserFilter(
|
|
blocklist=self.config.users.blocklist,
|
|
allowlist=self.config.users.allowlist,
|
|
allowlist_only=self.config.users.allowlist_only,
|
|
admin_nodes=self.config.users.admin_nodes,
|
|
)
|
|
self.safety_filter = SafetyFilter(self.config.safety)
|
|
|
|
# Rate limiter (2b)
|
|
self.rate_limiter = RateLimiter(
|
|
self.config.rate_limits,
|
|
vip_nodes=self.config.users.vip_nodes,
|
|
)
|
|
|
|
# Personality manager (2c)
|
|
self.personality = PersonalityManager(self.config.personality)
|
|
|
|
# LLM backend
|
|
api_key = self.config.resolve_api_key()
|
|
if not api_key:
|
|
logger.warning("No API key configured - LLM responses will fail")
|
|
|
|
# Memory config
|
|
mem_cfg = self.config.memory
|
|
window_size = mem_cfg.window_size if mem_cfg.enabled else 0
|
|
summarize_threshold = mem_cfg.summarize_threshold
|
|
|
|
# Create primary backend
|
|
backend = self.config.llm.backend.lower()
|
|
if backend == "openai":
|
|
primary = OpenAIBackend(
|
|
self.config.llm, api_key, window_size, summarize_threshold
|
|
)
|
|
elif backend == "anthropic":
|
|
primary = AnthropicBackend(
|
|
self.config.llm, api_key, window_size, summarize_threshold
|
|
)
|
|
elif backend == "google":
|
|
primary = GoogleBackend(
|
|
self.config.llm, api_key, window_size, summarize_threshold
|
|
)
|
|
else:
|
|
logger.warning(f"Unknown backend '{backend}', defaulting to OpenAI")
|
|
primary = OpenAIBackend(
|
|
self.config.llm, api_key, window_size, summarize_threshold
|
|
)
|
|
|
|
# Wrap in FallbackBackend if fallback is configured (2g)
|
|
if self.config.llm.fallback:
|
|
self.llm = FallbackBackend(
|
|
self.config.llm, api_key, window_size, summarize_threshold
|
|
)
|
|
else:
|
|
self.llm = primary
|
|
|
|
# Load persisted summaries into memory cache
|
|
await self._load_summaries()
|
|
|
|
# Meshtastic connector
|
|
self.connector = MeshConnector(self.config.connection)
|
|
|
|
# Message router (pass personality manager)
|
|
self.router = MessageRouter(
|
|
self.config, self.connector, self.history, self.dispatcher, self.llm,
|
|
personality=self.personality,
|
|
)
|
|
|
|
# Responder
|
|
self.responder = Responder(self.config.response, self.connector)
|
|
|
|
# Webhook client (2d)
|
|
self.webhook = WebhookClient(self.config.integrations.webhook)
|
|
|
|
# Web status server (2e)
|
|
self.web_status = WebStatusServer(self.config.web_status)
|
|
|
|
# Announcement scheduler (2f)
|
|
async def _send_announcement(text: str, channel: int) -> None:
|
|
self.connector.send_message(text=text, channel=channel)
|
|
|
|
self.announcements = AnnouncementScheduler(
|
|
self.config.announcements,
|
|
send_callback=_send_announcement,
|
|
)
|
|
|
|
async def _on_message(self, message: MeshMessage) -> None:
|
|
"""Handle incoming message."""
|
|
try:
|
|
# Check user filter (2a)
|
|
allowed, reason = self.user_filter.is_allowed(message.sender_id)
|
|
if not allowed:
|
|
logger.debug(f"Blocked message from {message.sender_id}: {reason}")
|
|
return
|
|
|
|
# Check if we should respond
|
|
if not self.router.should_respond(message):
|
|
return
|
|
|
|
# Check rate limiter (2b)
|
|
allowed, reason = self.rate_limiter.is_allowed(message.sender_id)
|
|
if not allowed:
|
|
logger.debug(f"Rate limited {message.sender_id}: {reason}")
|
|
return
|
|
|
|
logger.info(
|
|
f"Processing message from {message.sender_name} ({message.sender_id}): "
|
|
f"{message.text[:50]}..."
|
|
)
|
|
|
|
# Record in web status (2e)
|
|
get_status_data().record_message(message.sender_id, message.sender_name)
|
|
|
|
# Send webhook event (2d)
|
|
await self.webhook.on_message_received(
|
|
sender_id=message.sender_id,
|
|
sender_name=message.sender_name,
|
|
text=message.text,
|
|
channel=message.channel,
|
|
is_dm=message.is_dm,
|
|
)
|
|
|
|
# Route the message
|
|
result = await self.router.route(message)
|
|
|
|
if result.route_type == RouteType.IGNORE:
|
|
return
|
|
|
|
# Determine response
|
|
if result.route_type == RouteType.COMMAND:
|
|
response = result.response
|
|
elif result.route_type == RouteType.LLM:
|
|
response = await self.router.generate_llm_response(message, result.query)
|
|
else:
|
|
return
|
|
|
|
if not response:
|
|
return
|
|
|
|
# Apply safety filter to LLM responses (2a)
|
|
if result.route_type == RouteType.LLM:
|
|
response = self.safety_filter.filter_response(response)
|
|
|
|
# Send response
|
|
if message.is_dm:
|
|
await self.responder.send_response(
|
|
text=response,
|
|
destination=message.sender_id,
|
|
channel=message.channel,
|
|
)
|
|
else:
|
|
formatted = self.responder.format_channel_response(
|
|
response, message.sender_name, mention_sender=True
|
|
)
|
|
await self.responder.send_response(
|
|
text=formatted,
|
|
destination=None,
|
|
channel=message.channel,
|
|
)
|
|
|
|
# Record response in rate limiter and status (2b, 2e)
|
|
self.rate_limiter.record_message(message.sender_id)
|
|
get_status_data().record_response()
|
|
|
|
# Send webhook event (2d)
|
|
await self.webhook.on_response_sent(
|
|
recipient_id=message.sender_id if message.is_dm else None,
|
|
text=response,
|
|
channel=message.channel,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error handling message: {e}", exc_info=True)
|
|
get_status_data().record_error(str(e))
|
|
await self.webhook.on_error(str(e))
|
|
|
|
async def _load_summaries(self) -> None:
|
|
"""Load persisted summaries from database into memory cache."""
|
|
memory = self.llm.get_memory()
|
|
if not memory:
|
|
return
|
|
|
|
if not self.history or not self.history._db:
|
|
return
|
|
|
|
try:
|
|
async with self.history._lock:
|
|
cursor = await self.history._db.execute(
|
|
"SELECT user_id, summary, message_count, updated_at "
|
|
"FROM conversation_summaries"
|
|
)
|
|
rows = await cursor.fetchall()
|
|
|
|
loaded = 0
|
|
for row in rows:
|
|
user_id, summary_text, message_count, updated_at = row
|
|
summary = ConversationSummary(
|
|
summary=summary_text,
|
|
last_updated=updated_at,
|
|
message_count=message_count,
|
|
)
|
|
memory.load_summary(user_id, summary)
|
|
loaded += 1
|
|
|
|
if loaded:
|
|
logger.info(f"Loaded {loaded} conversation summaries from database")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load summaries from database: {e}")
|
|
|
|
def _write_pid(self) -> None:
|
|
"""Write PID file."""
|
|
pid_file = Path("/tmp/meshai.pid")
|
|
pid_file.write_text(str(os.getpid()))
|
|
|
|
def _remove_pid(self) -> None:
|
|
"""Remove PID file."""
|
|
pid_file = Path("/tmp/meshai.pid")
|
|
if pid_file.exists():
|
|
pid_file.unlink()
|
|
|
|
|
|
def setup_logging(verbose: bool = False) -> None:
|
|
"""Configure logging."""
|
|
level = logging.DEBUG if verbose else logging.INFO
|
|
logging.basicConfig(
|
|
level=level,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
"""Main entry point."""
|
|
parser = argparse.ArgumentParser(
|
|
description="MeshAI - LLM-powered Meshtastic assistant",
|
|
prog="meshai",
|
|
)
|
|
parser.add_argument(
|
|
"--version", "-V", action="version", version=f"%(prog)s {__version__}"
|
|
)
|
|
parser.add_argument(
|
|
"--config", "-c", action="store_true", help="Launch configuration tool"
|
|
)
|
|
parser.add_argument(
|
|
"--config-file",
|
|
"-f",
|
|
type=Path,
|
|
default=Path("config.yaml"),
|
|
help="Path to config file (default: config.yaml)",
|
|
)
|
|
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
|
|
|
|
args = parser.parse_args()
|
|
|
|
setup_logging(args.verbose)
|
|
|
|
# Launch configurator if requested
|
|
if args.config:
|
|
run_configurator(args.config_file)
|
|
return
|
|
|
|
# Load config
|
|
config = load_config(args.config_file)
|
|
|
|
# Check if config exists
|
|
if not args.config_file.exists():
|
|
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)
|
|
|
|
# Handle SIGHUP for config reload
|
|
def reload_handler(sig, frame):
|
|
logger.info("Received SIGHUP - reloading config")
|
|
# For now, just log - full reload would require more work
|
|
# Could reload config and reinitialize components
|
|
|
|
signal.signal(signal.SIGHUP, reload_handler)
|
|
|
|
try:
|
|
loop.run_until_complete(bot.start())
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
loop.run_until_complete(bot.stop())
|
|
loop.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|