#!/usr/bin/env python3
"""
MeshCore Bot Data Viewer
Bot montoring web interface using Flask-SocketIO 5.x
"""
import sqlite3
import json
import time
import configparser
import logging
import threading
from datetime import datetime, timedelta, date
from flask import Flask, render_template, jsonify, request
from flask_socketio import SocketIO, emit, join_room, leave_room, disconnect
from pathlib import Path
import os
import sys
from typing import Dict, Any, Optional, List
# Add the project root to the path so we can import bot components
project_root = os.path.join(os.path.dirname(__file__), '..', '..')
sys.path.insert(0, project_root)
from modules.db_manager import DBManager
from modules.repeater_manager import RepeaterManager
from modules.utils import resolve_path
class BotDataViewer:
"""Complete web interface using Flask-SocketIO 5.x best practices"""
def __init__(self, db_path="meshcore_bot.db", repeater_db_path=None, config_path="config.ini"):
# Setup comprehensive logging
self._setup_logging()
# Set bot root directory (project root) for path validation
# This is the directory containing the modules folder
self.bot_root = Path(os.path.join(os.path.dirname(__file__), '..', '..')).resolve()
self.app = Flask(
__name__,
template_folder=os.path.join(os.path.dirname(__file__), 'templates'),
static_folder=os.path.join(os.path.dirname(__file__), 'static'),
static_url_path='/static'
)
self.app.config['SECRET_KEY'] = 'meshcore_bot_viewer_secret'
# Flask-SocketIO configuration following 5.x best practices
self.socketio = SocketIO(
self.app,
cors_allowed_origins="*",
max_http_buffer_size=1000000, # 1MB buffer limit
ping_timeout=5, # 5 second ping timeout (Flask-SocketIO 5.x default)
ping_interval=25, # 25 second ping interval (Flask-SocketIO 5.x default)
logger=False, # Disable verbose logging
engineio_logger=False, # Disable EngineIO logging
async_mode='threading' # Use threading for better stability
)
self.repeater_db_path = repeater_db_path
# Connection management using Flask-SocketIO built-ins
self.connected_clients = {} # Track client metadata
self._clients_lock = threading.Lock() # Thread safety for connected_clients
self.max_clients = 10
# Database connection pooling with thread safety
self._db_connection = None
self._db_lock = threading.Lock()
self._db_last_used = 0
self._db_timeout = 300 # 5 minutes connection timeout
# Load configuration
self.config = self._load_config(config_path)
# Use [Bot] db_path when [Web_Viewer] db_path is unset
bot_db = self.config.get('Bot', 'db_path', fallback='meshcore_bot.db')
if (self.config.has_section('Web_Viewer') and self.config.has_option('Web_Viewer', 'db_path')
and self.config.get('Web_Viewer', 'db_path', fallback='').strip()):
use_db = self.config.get('Web_Viewer', 'db_path').strip()
else:
use_db = bot_db
self.db_path = str(resolve_path(use_db, self.bot_root))
# Setup template context processor for global template variables
self._setup_template_context()
# Initialize databases
self._init_databases()
# Setup routes and SocketIO handlers
self._setup_routes()
self._setup_socketio_handlers()
# Start database polling for real-time data
self._start_database_polling()
# Start periodic cleanup
self._start_cleanup_scheduler()
self.logger.info("BotDataViewer initialized with Flask-SocketIO 5.x best practices")
def _setup_logging(self):
"""Setup comprehensive logging with rotation"""
from logging.handlers import RotatingFileHandler
# Create logs directory if it doesn't exist
os.makedirs('logs', exist_ok=True)
# Get or create logger (don't use basicConfig as it may conflict with existing logging)
self.logger = logging.getLogger('modern_web_viewer')
self.logger.setLevel(logging.DEBUG)
# Remove existing handlers to avoid duplicates
self.logger.handlers.clear()
# Create rotating file handler (max 5MB per file, keep 3 backups)
file_handler = RotatingFileHandler(
'logs/web_viewer_modern.log',
maxBytes=5 * 1024 * 1024, # 5 MB
backupCount=3,
encoding='utf-8'
)
file_handler.setLevel(logging.DEBUG)
file_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler.setFormatter(file_formatter)
self.logger.addHandler(file_handler)
# Create console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
console_handler.setFormatter(console_formatter)
self.logger.addHandler(console_handler)
# Prevent propagation to root logger to avoid duplicate messages
self.logger.propagate = False
self.logger.info("Web viewer logging initialized with rotation (5MB max, 3 backups)")
def _load_config(self, config_path):
"""Load configuration from file"""
config = configparser.ConfigParser()
if os.path.exists(config_path):
config.read(config_path)
return config
def _setup_template_context(self):
"""Setup template context processor to inject global variables"""
@self.app.context_processor
def inject_template_vars():
"""Inject variables available to all templates"""
# Check if greeter is enabled, defaulting to False if section doesn't exist
try:
greeter_enabled = self.config.getboolean('Greeter_Command', 'enabled', fallback=False)
except (configparser.NoSectionError, configparser.NoOptionError):
greeter_enabled = False
# Check if feed manager is enabled, defaulting to False if section doesn't exist
try:
feed_manager_enabled = self.config.getboolean('Feed_Manager', 'feed_manager_enabled', fallback=False)
except (configparser.NoSectionError, configparser.NoOptionError):
feed_manager_enabled = False
return dict(greeter_enabled=greeter_enabled, feed_manager_enabled=feed_manager_enabled)
def _get_db_path(self):
"""Get the database path, falling back to [Bot] db_path if [Web_Viewer] db_path is unset"""
# Use [Bot] db_path when [Web_Viewer] db_path is unset
bot_db = self.config.get('Bot', 'db_path', fallback='meshcore_bot.db')
if (self.config.has_section('Web_Viewer') and self.config.has_option('Web_Viewer', 'db_path')
and self.config.get('Web_Viewer', 'db_path', fallback='').strip()):
use_db = self.config.get('Web_Viewer', 'db_path').strip()
else:
use_db = bot_db
return str(resolve_path(use_db, self.bot_root))
def _init_databases(self):
"""Initialize database connections"""
try:
# Initialize database manager for metadata access
from modules.db_manager import DBManager
# Create a minimal bot object for DBManager
class MinimalBot:
def __init__(self, logger, config, db_manager=None):
self.logger = logger
self.config = config
self.db_manager = db_manager
# Create DBManager first
minimal_bot = MinimalBot(self.logger, self.config)
self.db_manager = DBManager(minimal_bot, self.db_path)
# Now set db_manager on the minimal bot for RepeaterManager
minimal_bot.db_manager = self.db_manager
# Initialize repeater manager for geocoding functionality
self.repeater_manager = RepeaterManager(minimal_bot)
# Initialize packet_stream table for real-time monitoring
self._init_packet_stream_table()
# Store database paths for direct connection
self.db_path = self.db_path
self.repeater_db_path = self.repeater_db_path
self.logger.info("Database connections initialized")
except Exception as e:
self.logger.error(f"Failed to initialize databases: {e}")
raise
def _init_packet_stream_table(self):
"""Initialize the packet_stream table in the database"""
conn = None
try:
# Use the same database path that was set in __init__
db_path = self.db_path
# Connect to database and create table if it doesn't exist
with sqlite3.connect(db_path, timeout=30) as conn:
cursor = conn.cursor()
# Create packet_stream table with schema matching the INSERT statements
cursor.execute('''
CREATE TABLE IF NOT EXISTS packet_stream (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL NOT NULL,
data TEXT NOT NULL,
type TEXT NOT NULL
)
''')
# Create index on timestamp for faster queries
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_packet_stream_timestamp
ON packet_stream(timestamp)
''')
# Create index on type for filtering by type
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_packet_stream_type
ON packet_stream(type)
''')
conn.commit()
self.logger.info(f"Initialized packet_stream table in {db_path}")
except Exception as e:
self.logger.error(f"Failed to initialize packet_stream table: {e}")
# Don't raise - allow web viewer to continue even if table init fails
def _get_db_connection(self):
"""Get database connection - create new connection for each request to avoid threading issues"""
try:
conn = sqlite3.connect(self.db_path, timeout=30)
conn.row_factory = sqlite3.Row
return conn
except Exception as e:
self.logger.error(f"Failed to create database connection: {e}")
raise
def _setup_routes(self):
"""Setup all Flask routes - complete feature parity"""
@self.app.route('/')
def index():
"""Main dashboard"""
return render_template('index.html')
@self.app.route('/realtime')
def realtime():
"""Real-time monitoring dashboard"""
return render_template('realtime.html')
@self.app.route('/contacts')
def contacts():
"""Contacts page - unified contact management and tracking"""
return render_template('contacts.html')
@self.app.route('/cache')
def cache():
"""Cache management page"""
return render_template('cache.html')
@self.app.route('/stats')
def stats():
"""Statistics page"""
return render_template('stats.html')
@self.app.route('/greeter')
def greeter():
"""Greeter management page"""
return render_template('greeter.html')
@self.app.route('/feeds')
def feeds():
"""Feed management page"""
return render_template('feeds.html')
@self.app.route('/radio')
def radio():
"""Radio settings page"""
return render_template('radio.html')
# API Routes
@self.app.route('/api/health')
def api_health():
"""Health check endpoint"""
# Get bot uptime
bot_uptime = self._get_bot_uptime()
with self._clients_lock:
client_count = len(self.connected_clients)
return jsonify({
'status': 'healthy',
'connected_clients': client_count,
'max_clients': self.max_clients,
'timestamp': time.time(),
'bot_uptime': bot_uptime,
'version': 'modern_2.0'
})
@self.app.route('/api/system-health')
def api_system_health():
"""Get comprehensive system health status from database"""
try:
# Read health data from database (consistent with how other data is accessed)
health_data = self.db_manager.get_system_health()
if not health_data:
# If no health data in database, return minimal status
return jsonify({
'status': 'unknown',
'timestamp': time.time(),
'message': 'Health data not available yet',
'components': {}
})
# Update timestamp to reflect current time (data may be slightly stale)
health_data['timestamp'] = time.time()
# Recalculate uptime if start_time is available
start_time = self.db_manager.get_bot_start_time()
if start_time:
health_data['uptime_seconds'] = time.time() - start_time
return jsonify(health_data)
except Exception as e:
self.logger.error(f"Error getting system health: {e}")
import traceback
self.logger.debug(traceback.format_exc())
return jsonify({
'error': str(e),
'status': 'error'
}), 500
@self.app.route('/api/stats')
def api_stats():
"""Get comprehensive database statistics for dashboard"""
try:
# Get optional time window parameters for analytics
top_users_window = request.args.get('top_users_window', 'all')
top_commands_window = request.args.get('top_commands_window', 'all')
top_paths_window = request.args.get('top_paths_window', 'all')
top_channels_window = request.args.get('top_channels_window', 'all')
stats = self._get_database_stats(
top_users_window=top_users_window,
top_commands_window=top_commands_window,
top_paths_window=top_paths_window,
top_channels_window=top_channels_window
)
return jsonify(stats)
except Exception as e:
self.logger.error(f"Error getting stats: {e}")
return jsonify({'error': str(e)}), 500
@self.app.route('/api/contacts')
def api_contacts():
"""Get contact data"""
try:
contacts = self._get_tracking_data()
return jsonify(contacts)
except Exception as e:
self.logger.error(f"Error getting contacts: {e}")
return jsonify({'error': str(e)}), 500
@self.app.route('/api/cache')
def api_cache():
"""Get cache data"""
try:
cache_data = self._get_cache_data()
return jsonify(cache_data)
except Exception as e:
self.logger.error(f"Error getting cache: {e}")
return jsonify({'error': str(e)}), 500
@self.app.route('/api/database')
def api_database():
"""Get database information"""
try:
db_info = self._get_database_info()
return jsonify(db_info)
except Exception as e:
self.logger.error(f"Error getting database info: {e}")
return jsonify({'error': str(e)}), 500
@self.app.route('/api/optimize-database', methods=['POST'])
def api_optimize_database():
"""Optimize database using VACUUM, ANALYZE, and REINDEX"""
try:
result = self._optimize_database()
return jsonify(result)
except Exception as e:
self.logger.error(f"Error optimizing database: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@self.app.route('/api/stream_data', methods=['POST'])
def api_stream_data():
"""API endpoint for receiving real-time data from bot"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
data_type = data.get('type')
if data_type == 'command':
self._handle_command_data(data.get('data', {}))
elif data_type == 'packet':
self._handle_packet_data(data.get('data', {}))
else:
return jsonify({'error': 'Invalid data type'}), 400
return jsonify({'status': 'success'})
except Exception as e:
self.logger.error(f"Error in stream_data endpoint: {e}")
return jsonify({'error': str(e)}), 500
@self.app.route('/api/recent_commands')
def api_recent_commands():
"""API endpoint to get recent commands from database"""
try:
import sqlite3
import json
import time
# Get commands from last 60 minutes
cutoff_time = time.time() - (60 * 60) # 60 minutes ago
# Get database path (falls back to [Bot] db_path if [Web_Viewer] db_path is unset)
db_path = self._get_db_path()
with sqlite3.connect(db_path, timeout=30) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT data FROM packet_stream
WHERE type = 'command' AND timestamp > ?
ORDER BY timestamp DESC
LIMIT 100
''', (cutoff_time,))
rows = cursor.fetchall()
# Parse and return commands
commands = []
for (data_json,) in rows:
try:
command_data = json.loads(data_json)
commands.append(command_data)
except Exception as e:
self.logger.debug(f"Error parsing command data: {e}")
return jsonify({'commands': commands})
except Exception as e:
self.logger.error(f"Error getting recent commands: {e}")
return jsonify({'error': str(e)}), 500
@self.app.route('/api/geocode-contact', methods=['POST'])
def api_geocode_contact():
"""Manually geocode a contact by public_key"""
conn = None
try:
data = request.get_json()
if not data or 'public_key' not in data:
return jsonify({'error': 'public_key is required'}), 400
public_key = data['public_key']
# Get contact data from database
conn = self._get_db_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT latitude, longitude, name, city, state, country
FROM complete_contact_tracking
WHERE public_key = ?
''', (public_key,))
contact = cursor.fetchone()
if not contact:
return jsonify({'error': 'Contact not found'}), 404
lat = contact['latitude']
lon = contact['longitude']
name = contact['name']
# Check if we have valid coordinates
if lat is None or lon is None or lat == 0.0 or lon == 0.0:
return jsonify({'error': 'Contact does not have valid coordinates'}), 400
# Perform geocoding
self.logger.info(f"Manual geocoding requested for {name} ({public_key[:16]}...) at coordinates {lat}, {lon}")
# sqlite3.Row objects use dictionary-style access with []
current_city = contact['city']
current_state = contact['state']
current_country = contact['country']
self.logger.debug(f"Current location data - city: {current_city}, state: {current_state}, country: {current_country}")
try:
location_info = self.repeater_manager._get_full_location_from_coordinates(lat, lon)
self.logger.debug(f"Geocoding result for {name}: {location_info}")
except Exception as geocode_error:
self.logger.error(f"Exception during geocoding for {name} at {lat}, {lon}: {geocode_error}", exc_info=True)
return jsonify({
'success': False,
'error': f'Geocoding exception: {str(geocode_error)}',
'location': {}
}), 500
# Check if geocoding returned any useful data
has_location_data = location_info.get('city') or location_info.get('state') or location_info.get('country')
if not has_location_data:
self.logger.warning(f"Geocoding returned no location data for {name} at {lat}, {lon}. Result: {location_info}")
return jsonify({
'success': False,
'error': 'Geocoding returned no location data. The coordinates may be invalid or the geocoding service may be unavailable.',
'location': location_info
}), 500
# Update database with new location data
cursor.execute('''
UPDATE complete_contact_tracking
SET city = ?, state = ?, country = ?
WHERE public_key = ?
''', (
location_info.get('city'),
location_info.get('state'),
location_info.get('country'),
public_key
))
conn.commit()
# Build success message with what was found
found_parts = []
if location_info.get('city'):
found_parts.append(f"city: {location_info['city']}")
if location_info.get('state'):
found_parts.append(f"state: {location_info['state']}")
if location_info.get('country'):
found_parts.append(f"country: {location_info['country']}")
success_message = f'Successfully geocoded {name} - Found {", ".join(found_parts)}'
self.logger.info(f"Successfully geocoded {name}: {location_info}")
return jsonify({
'success': True,
'location': location_info,
'message': success_message
})
except Exception as e:
self.logger.error(f"Error geocoding contact: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
finally:
if conn:
conn.close()
@self.app.route('/api/toggle-star-contact', methods=['POST'])
def api_toggle_star_contact():
"""Toggle star status for a contact by public_key (only for repeaters and roomservers)"""
conn = None
try:
data = request.get_json()
if not data or 'public_key' not in data:
return jsonify({'error': 'public_key is required'}), 400
public_key = data['public_key']
# Get contact data from database
conn = self._get_db_connection()
cursor = conn.cursor()
# Check if contact exists and is a repeater or roomserver
cursor.execute('''
SELECT name, is_starred, role FROM complete_contact_tracking
WHERE public_key = ?
''', (public_key,))
contact = cursor.fetchone()
if not contact:
return jsonify({'error': 'Contact not found'}), 404
# Only allow starring repeaters and roomservers
# sqlite3.Row objects use dictionary-style access with []
role = contact['role']
if role and role.lower() not in ('repeater', 'roomserver'):
return jsonify({'error': 'Only repeaters and roomservers can be starred'}), 400
# Toggle star status
# sqlite3.Row objects use dictionary-style access with []
current_starred = contact['is_starred']
new_star_status = 1 if not current_starred else 0
cursor.execute('''
UPDATE complete_contact_tracking
SET is_starred = ?
WHERE public_key = ?
''', (new_star_status, public_key))
conn.commit()
action = 'starred' if new_star_status else 'unstarred'
self.logger.info(f"Contact {contact['name']} ({public_key[:16]}...) {action}")
return jsonify({
'success': True,
'is_starred': bool(new_star_status),
'message': f'Contact {action} successfully'
})
except Exception as e:
self.logger.error(f"Error toggling star status: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
finally:
if conn:
conn.close()
@self.app.route('/api/decode-path', methods=['POST'])
def api_decode_path():
"""Decode path hex string to repeater names (similar to path command)"""
try:
data = request.get_json()
if not data or 'path_hex' not in data:
return jsonify({'error': 'path_hex is required'}), 400
path_hex = data['path_hex']
if not path_hex:
return jsonify({'error': 'path_hex cannot be empty'}), 400
# Decode the path
decoded_path = self._decode_path_hex(path_hex)
return jsonify({
'success': True,
'path': decoded_path
})
except Exception as e:
self.logger.error(f"Error decoding path: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
@self.app.route('/api/delete-contact', methods=['POST'])
def api_delete_contact():
"""Delete a contact from the complete contact tracking database"""
conn = None
try:
data = request.get_json()
if not data or 'public_key' not in data:
return jsonify({'error': 'public_key is required'}), 400
public_key = data['public_key']
# Get contact data from database to log what we're deleting
conn = self._get_db_connection()
cursor = conn.cursor()
# Check if contact exists
cursor.execute('''
SELECT name, role, device_type FROM complete_contact_tracking
WHERE public_key = ?
''', (public_key,))
contact = cursor.fetchone()
if not contact:
return jsonify({'error': 'Contact not found'}), 404
contact_name = contact['name']
contact_role = contact['role']
contact_device_type = contact['device_type']
# Delete from all related tables
deleted_counts = {}
# Delete from complete_contact_tracking
cursor.execute('DELETE FROM complete_contact_tracking WHERE public_key = ?', (public_key,))
deleted_counts['complete_contact_tracking'] = cursor.rowcount
# Delete from daily_stats
cursor.execute('DELETE FROM daily_stats WHERE public_key = ?', (public_key,))
deleted_counts['daily_stats'] = cursor.rowcount
# Delete from repeater_contacts if it exists
try:
cursor.execute('DELETE FROM repeater_contacts WHERE public_key = ?', (public_key,))
deleted_counts['repeater_contacts'] = cursor.rowcount
except sqlite3.OperationalError:
# Table might not exist, that's okay
deleted_counts['repeater_contacts'] = 0
conn.commit()
# Log the deletion
self.logger.info(f"Contact deleted: {contact_name} ({public_key[:16]}...) - Role: {contact_role}, Device: {contact_device_type}")
self.logger.debug(f"Deleted counts: {deleted_counts}")
return jsonify({
'success': True,
'message': f'Contact "{contact_name}" has been deleted successfully',
'deleted_counts': deleted_counts
})
except Exception as e:
self.logger.error(f"Error deleting contact: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
finally:
if conn:
conn.close()
@self.app.route('/api/greeter')
def api_greeter():
"""Get greeter data including rollout status, settings, and greeted users"""
conn = None
try:
conn = self._get_db_connection()
cursor = conn.cursor()
# Check if greeter tables exist
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='greeter_rollout'")
if not cursor.fetchone():
return jsonify({
'enabled': False,
'rollout_active': False,
'settings': {},
'greeted_users': [],
'error': 'Greeter tables not found'
})
# Get active rollout status
cursor.execute('''
SELECT id, rollout_started_at, rollout_days, rollout_completed,
datetime(rollout_started_at, '+' || rollout_days || ' days') as end_date,
datetime('now') as current_time
FROM greeter_rollout
WHERE rollout_completed = 0
ORDER BY rollout_started_at DESC
LIMIT 1
''')
rollout = cursor.fetchone()
rollout_active = False
rollout_data = None
time_remaining = None
if rollout:
rollout_id = rollout['id']
started_at_str = rollout['rollout_started_at']
rollout_days = rollout['rollout_days']
end_date_str = rollout['end_date']
current_time_str = rollout['current_time']
end_date = datetime.fromisoformat(end_date_str)
current_time = datetime.fromisoformat(current_time_str)
if current_time < end_date:
rollout_active = True
remaining_seconds = (end_date - current_time).total_seconds()
time_remaining = {
'days': int(remaining_seconds // 86400),
'hours': int((remaining_seconds % 86400) // 3600),
'minutes': int((remaining_seconds % 3600) // 60),
'seconds': int(remaining_seconds % 60),
'total_seconds': int(remaining_seconds)
}
rollout_data = {
'id': rollout_id,
'started_at': started_at_str,
'days': rollout_days,
'end_date': end_date_str
}
# Get greeter settings from config
settings = {
'enabled': self.config.getboolean('Greeter_Command', 'enabled', fallback=False),
'greeting_message': self.config.get('Greeter_Command', 'greeting_message',
fallback='Welcome to the mesh, {sender}!'),
'rollout_days': self.config.getint('Greeter_Command', 'rollout_days', fallback=7),
'include_mesh_info': self.config.getboolean('Greeter_Command', 'include_mesh_info',
fallback=True),
'mesh_info_format': self.config.get('Greeter_Command', 'mesh_info_format',
fallback='\n\nMesh Info: {total_contacts} contacts, {repeaters} repeaters'),
'per_channel_greetings': self.config.getboolean('Greeter_Command', 'per_channel_greetings',
fallback=False)
}
# Generate sample greeting
sample_greeting = settings['greeting_message'].format(sender='SampleUser')
if settings['include_mesh_info']:
sample_mesh_info = settings['mesh_info_format'].format(
total_contacts=100,
repeaters=5,
companions=95,
recent_activity_24h=10
)
sample_greeting += sample_mesh_info
# Check if message_stats table exists for last seen data
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='message_stats'")
has_message_stats = cursor.fetchone() is not None
# Get greeted users - use GROUP BY to ensure only one entry per (sender_id, channel)
# This handles any potential duplicates that might exist in the database
# We use MIN(greeted_at) to get the earliest (first) greeting time
# If per_channel_greetings is False, we'll still show one entry per user (channel will be NULL)
# If per_channel_greetings is True, we'll show one entry per user per channel
cursor.execute('''
SELECT sender_id, channel, MIN(greeted_at) as greeted_at,
MAX(rollout_marked) as rollout_marked
FROM greeted_users
GROUP BY sender_id, channel
ORDER BY MIN(greeted_at) DESC
LIMIT 500
''')
greeted_users_rows = cursor.fetchall()
greeted_users = []
for row in greeted_users_rows:
# Access row data - handle both dict-style (Row) and tuple access
try:
sender_id = row['sender_id'] if isinstance(row, dict) or hasattr(row, '__getitem__') else row[0]
channel_raw = row['channel'] if isinstance(row, dict) or hasattr(row, '__getitem__') else row[1]
greeted_at = row['greeted_at'] if isinstance(row, dict) or hasattr(row, '__getitem__') else row[2]
rollout_marked = row['rollout_marked'] if isinstance(row, dict) or hasattr(row, '__getitem__') else row[3]
except (KeyError, IndexError, TypeError) as e:
self.logger.error(f"Error accessing row data: {e}, row type: {type(row)}")
continue
sender_id = str(sender_id) if sender_id else ''
channel = str(channel_raw) if channel_raw else '(global)'
# Get last seen timestamp from message_stats if available
last_seen = None
if has_message_stats:
# Get the most recent channel message (not DM) for this user
# If per_channel_greetings is enabled, match the specific channel
# Otherwise, get the most recent message from any channel
if channel_raw: # Use the raw channel value, not the formatted one
cursor.execute('''
SELECT MAX(timestamp) as last_seen
FROM message_stats
WHERE sender_id = ?
AND channel = ?
AND is_dm = 0
AND channel IS NOT NULL
''', (sender_id, channel_raw))
else:
# Global greeting - get last seen from any channel
cursor.execute('''
SELECT MAX(timestamp) as last_seen
FROM message_stats
WHERE sender_id = ?
AND is_dm = 0
AND channel IS NOT NULL
''', (sender_id,))
result = cursor.fetchone()
if result and result['last_seen']:
last_seen = result['last_seen']
greeted_users.append({
'sender_id': sender_id,
'channel': channel,
'greeted_at': str(greeted_at),
'rollout_marked': bool(rollout_marked),
'last_seen': last_seen
})
return jsonify({
'enabled': settings['enabled'],
'rollout_active': rollout_active,
'rollout_data': rollout_data,
'time_remaining': time_remaining,
'settings': settings,
'sample_greeting': sample_greeting,
'greeted_users': greeted_users,
'total_greeted': len(greeted_users)
})
except Exception as e:
self.logger.error(f"Error getting greeter data: {e}", exc_info=True)
return jsonify({'error': str(e)}), 500
finally:
if conn:
conn.close()
@self.app.route('/api/greeter/end-rollout', methods=['POST'])
def api_end_rollout():
"""End the active onboarding period"""
conn = None
try:
conn = self._get_db_connection()
cursor = conn.cursor()
# Find active rollout
cursor.execute('''
SELECT id FROM greeter_rollout
WHERE rollout_completed = 0
ORDER BY rollout_started_at DESC
LIMIT 1
''')
rollout = cursor.fetchone()
if not rollout:
return jsonify({'success': False, 'error': 'No active rollout found'}), 404
rollout_id = rollout['id']
# Mark rollout as completed
cursor.execute('''
UPDATE greeter_rollout
SET rollout_completed = 1
WHERE id = ?
''', (rollout_id,))
conn.commit()
self.logger.info(f"Greeter rollout {rollout_id} ended manually via web viewer")
return jsonify({
'success': True,
'message': 'Onboarding period ended successfully'
})
except Exception as e:
self.logger.error(f"Error ending rollout: {e}", exc_info=True)
return jsonify({'success': False, 'error': str(e)}), 500
finally:
if conn:
conn.close()
@self.app.route('/api/greeter/ungreet', methods=['POST'])
def api_ungreet_user():
"""Mark a user as ungreeted (remove from greeted_users table)"""
conn = None
try:
data = request.get_json()
if not data or 'sender_id' not in data:
return jsonify({'error': 'sender_id is required'}), 400
sender_id = data['sender_id']
channel = data.get('channel') # Optional - if None, removes global greeting
conn = self._get_db_connection()
cursor = conn.cursor()
# Check if user exists
if channel and channel != '(global)':
cursor.execute('''
SELECT id FROM greeted_users
WHERE sender_id = ? AND channel = ?
''', (sender_id, channel))
else:
cursor.execute('''
SELECT id FROM greeted_users
WHERE sender_id = ? AND channel IS NULL
''', (sender_id,))
if not cursor.fetchone():
return jsonify({'error': 'User not found in greeted users'}), 404
# Delete the record
if channel and channel != '(global)':
cursor.execute('''
DELETE FROM greeted_users
WHERE sender_id = ? AND channel = ?
''', (sender_id, channel))
else:
cursor.execute('''
DELETE FROM greeted_users
WHERE sender_id = ? AND channel IS NULL
''', (sender_id,))
conn.commit()
self.logger.info(f"User {sender_id} marked as ungreeted (channel: {channel or 'global'})")
return jsonify({
'success': True,
'message': f'User {sender_id} marked as ungreeted'
})
except Exception as e:
self.logger.error(f"Error ungreeting user: {e}", exc_info=True)
return jsonify({'success': False, 'error': str(e)}), 500
finally:
if conn:
conn.close()
# Feed management API endpoints
@self.app.route('/api/feeds')
def api_feeds():
"""Get all feed subscriptions with statistics"""
try:
feeds = self._get_feed_subscriptions()
return jsonify(feeds)
except Exception as e:
self.logger.error(f"Error getting feeds: {e}")
return jsonify({'error': str(e)}), 500
@self.app.route('/api/feeds/
,
,
,
, etc.
body = re.sub(r'
', '\n', body, flags=re.IGNORECASE)
# Convert paragraph tags to newlines (with spacing)
body = re.sub(r'
]*>', '', body, flags=re.IGNORECASE) # Remove remaining HTML tags body = re.sub(r'<[^>]+>', '', body) # Clean up whitespace (preserve intentional line breaks) # Replace multiple newlines with double newline, then normalize spaces within lines body = re.sub(r'\n\s*\n\s*\n+', '\n\n', body) # Multiple newlines -> double newline lines = body.split('\n') body = '\n'.join(' '.join(line.split()) for line in lines) # Normalize spaces per line body = body.strip() link = item.get('link', '') published = item.get('published') # Format timestamp date_str = "" if published: try: if published.tzinfo: now = datetime.now(timezone.utc) else: now = datetime.now() diff = now - published minutes = int(diff.total_seconds() / 60) if minutes < 1: date_str = "now" elif minutes < 60: date_str = f"{minutes}m ago" elif minutes < 1440: hours = minutes // 60 mins = minutes % 60 date_str = f"{hours}h {mins}m ago" else: days = minutes // 1440 date_str = f"{days}d ago" except Exception: pass # Choose emoji emoji = "📢" feed_name_lower = feed_name.lower() if 'emergency' in feed_name_lower or 'alert' in feed_name_lower: emoji = "🚨" elif 'warning' in feed_name_lower: emoji = "⚠️" elif 'info' in feed_name_lower or 'news' in feed_name_lower: emoji = "ℹ️" # Build replacements replacements = { 'title': title, 'body': body, 'date': date_str, 'link': link, 'emoji': emoji } # Get raw API data if available (for preview, we don't have raw data, so this will be empty) raw_data = item.get('raw', {}) # Helper to get nested values def get_nested_value(data, path, default=''): if not path or not data: return default parts = path.split('.') value = data for part in parts: if isinstance(value, dict): value = value.get(part) elif isinstance(value, list): try: idx = int(part) if 0 <= idx < len(value): value = value[idx] else: return default except (ValueError, TypeError): return default else: return default if value is None: return default return value if value is not None else default # Apply shortening, parsing, and conditional functions def apply_shortening(text: str, function: str) -> str: if not text: return "" if function.startswith('truncate:'): try: max_len = int(function.split(':', 1)[1]) if len(text) <= max_len: return text return text[:max_len] + "..." except (ValueError, IndexError): return text elif function.startswith('word_wrap:'): try: max_len = int(function.split(':', 1)[1]) if len(text) <= max_len: return text truncated = text[:max_len] last_space = truncated.rfind(' ') if last_space > max_len * 0.7: return truncated[:last_space] + "..." return truncated + "..." except (ValueError, IndexError): return text elif function.startswith('first_words:'): try: num_words = int(function.split(':', 1)[1]) words = text.split() if len(words) <= num_words: return text return ' '.join(words[:num_words]) + "..." except (ValueError, IndexError): return text elif function.startswith('regex:'): try: # Parse regex pattern and optional group number # Format: regex:pattern:group or regex:pattern # Need to handle patterns that contain colons, so split from the right remaining = function[6:] # Skip 'regex:' prefix # Try to find the last colon that's followed by a number (the group number) # Look for pattern like :N at the end last_colon_idx = remaining.rfind(':') pattern = remaining group_num = None if last_colon_idx > 0: # Check if what's after the last colon is a number potential_group = remaining[last_colon_idx + 1:] if potential_group.isdigit(): pattern = remaining[:last_colon_idx] group_num = int(potential_group) if not pattern: return text # Apply regex match = re.search(pattern, text, re.IGNORECASE | re.DOTALL) if match: if group_num is not None: # Use specified group (0 = whole match, 1 = first group, etc.) if 0 <= group_num <= len(match.groups()): return match.group(group_num) if group_num > 0 else match.group(0) else: # Use first capture group if available, otherwise whole match if match.groups(): return match.group(1) else: return match.group(0) return "" # No match found except (ValueError, IndexError, re.error) as e: # Silently fail on regex errors in preview return text elif function.startswith('if_regex:'): try: # Parse: if_regex:pattern:then:else # Split by ':' but need to handle regex patterns that contain ':' parts = function[9:].split(':', 2) # Skip 'if_regex:' prefix, split into [pattern, then, else] if len(parts) < 3: return text pattern = parts[0] then_value = parts[1] else_value = parts[2] if not pattern: return text # Check if pattern matches match = re.search(pattern, text, re.IGNORECASE | re.DOTALL) if match: return then_value else: return else_value except (ValueError, IndexError, re.error) as e: # Silently fail on regex errors in preview return text elif function.startswith('switch:'): try: # Parse: switch:value1:result1:value2:result2:...:default # Example: switch:highest:🔴:high:🟠:medium:🟡:low:⚪:⚪ parts = function[7:].split(':') # Skip 'switch:' prefix if len(parts) < 2: return text # Pairs of value:result, last one is default text_lower = text.lower().strip() for i in range(0, len(parts) - 1, 2): if i + 1 < len(parts): value = parts[i].lower() result = parts[i + 1] if text_lower == value: return result # Return last part as default if no match return parts[-1] if parts else text except (ValueError, IndexError) as e: # Silently fail on switch errors in preview return text elif function.startswith('regex_cond:'): try: # Parse: regex_cond:extract_pattern:check_pattern:then:group parts = function[11:].split(':', 3) # Skip 'regex_cond:' prefix if len(parts) < 4: return text extract_pattern = parts[0] check_pattern = parts[1] then_value = parts[2] else_group = int(parts[3]) if parts[3].isdigit() else 1 if not extract_pattern: return text # Extract using extract_pattern match = re.search(extract_pattern, text, re.IGNORECASE | re.DOTALL) if match: # Get the captured group if match.groups(): extracted = match.group(else_group) if else_group <= len(match.groups()) else match.group(1) # Strip whitespace from extracted text extracted = extracted.strip() else: extracted = match.group(0).strip() # Check if extracted text matches check_pattern (exact match or contains) if check_pattern: # Try exact match first, then substring match if extracted.lower() == check_pattern.lower() or re.search(check_pattern, extracted, re.IGNORECASE): return then_value return extracted return "" # No match found except (ValueError, IndexError, re.error) as e: # Silently fail on regex errors in preview return text return text # Process format string def replace_placeholder(match): content = match.group(1) if '|' in content: field_name, function = content.split('|', 1) field_name = field_name.strip() function = function.strip() # Check if it's a raw field access if field_name.startswith('raw.'): value = str(get_nested_value(raw_data, field_name[4:], '')) else: value = replacements.get(field_name, '') return apply_shortening(value, function) else: field_name = content.strip() # Check if it's a raw field access if field_name.startswith('raw.'): value = get_nested_value(raw_data, field_name[4:], '') if value is None: return '' elif isinstance(value, (dict, list)): try: import json return json.dumps(value) except Exception: return str(value) else: return str(value) else: return replacements.get(field_name, '') message = re.sub(r'\{([^}]+)\}', replace_placeholder, format_str) # Final truncation (130 char limit) max_length = 130 if len(message) > max_length: lines = message.split('\n') if len(lines) > 1: total_length = sum(len(line) + 1 for line in lines[:-1]) remaining = max_length - total_length - 3 if remaining > 20: lines[-1] = lines[-1][:remaining] + "..." message = '\n'.join(lines) else: message = message[:max_length - 3] + "..." else: message = message[:max_length - 3] + "..." return message def _get_bot_uptime(self): """Get bot uptime in seconds from database""" try: # Get start time from database metadata start_time = self.db_manager.get_bot_start_time() if start_time: return int(time.time() - start_time) else: # Fallback: try to get earliest message timestamp conn = self._get_db_connection() cursor = conn.cursor() # Try to get earliest message timestamp as fallback cursor.execute(""" SELECT MIN(timestamp) FROM message_stats WHERE timestamp IS NOT NULL """) result = cursor.fetchone() if result and result[0]: return int(time.time() - result[0]) return 0 except Exception as e: self.logger.debug(f"Could not get bot start time from database: {e}") return 0 def _add_channel_for_web(self, channel_idx, channel_name, channel_key_hex=None): """ Add a channel by queuing it in the database for the bot to process Args: channel_idx: Channel index (0-39) channel_name: Channel name (with or without # prefix) channel_key_hex: Optional hex key for custom channels (32 chars) Returns: dict with 'success' and optional 'error' key """ try: conn = self._get_db_connection() cursor = conn.cursor() # Insert operation into queue cursor.execute(''' INSERT INTO channel_operations (operation_type, channel_idx, channel_name, channel_key_hex, status) VALUES (?, ?, ?, ?, 'pending') ''', ('add', channel_idx, channel_name, channel_key_hex)) operation_id = cursor.lastrowid conn.commit() conn.close() self.logger.info(f"Queued channel add operation: {channel_name} at index {channel_idx} (operation_id: {operation_id})") # Return immediately with operation_id - let frontend poll for status return { 'success': True, 'pending': True, 'operation_id': operation_id, 'message': 'Channel operation queued successfully' } except Exception as e: self.logger.error(f"Error in _add_channel_for_web: {e}") return { 'success': False, 'error': str(e) } def _remove_channel_for_web(self, channel_idx): """ Remove a channel by queuing it in the database for the bot to process Args: channel_idx: Channel index to remove Returns: dict with 'success' and optional 'error' key """ try: conn = self._get_db_connection() cursor = conn.cursor() # Insert operation into queue cursor.execute(''' INSERT INTO channel_operations (operation_type, channel_idx, status) VALUES (?, ?, 'pending') ''', ('remove', channel_idx)) operation_id = cursor.lastrowid conn.commit() conn.close() self.logger.info(f"Queued channel remove operation: index {channel_idx} (operation_id: {operation_id})") # Return immediately with operation_id - let frontend poll for status return { 'success': True, 'pending': True, 'operation_id': operation_id, 'message': 'Channel operation queued successfully' } except Exception as e: self.logger.error(f"Error in _remove_channel_for_web: {e}") return { 'success': False, 'error': str(e) } def _decode_path_hex(self, path_hex: str) -> List[Dict[str, Any]]: """ Decode hex path string to repeater names. Returns a list of dictionaries with node_id and repeater info. """ import re # Parse the path input - handle various formats # Examples: "11,98,a4,49,cd,5f,01" or "11 98 a4 49 cd 5f 01" or "1198a449cd5f01" path_input = path_hex.replace(',', ' ').replace(':', ' ') # Extract hex values using regex hex_pattern = r'[0-9a-fA-F]{2}' hex_matches = re.findall(hex_pattern, path_input) if not hex_matches: return [] # Convert to uppercase for consistency node_ids = [match.upper() for match in hex_matches] # Look up repeater names for each node ID decoded_path = [] conn = None try: conn = self._get_db_connection() cursor = conn.cursor() for node_id in node_ids: # Query for all repeaters with matching prefix to detect collisions cursor.execute(''' SELECT name, public_key, device_type, role, COALESCE(last_advert_timestamp, last_heard) as last_seen FROM complete_contact_tracking WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') ORDER BY is_starred DESC, COALESCE(last_advert_timestamp, last_heard) DESC ''', (f"{node_id}%",)) results = cursor.fetchall() if results: # Check if there are multiple matches (collision) has_collision = len(results) > 1 # Use the first result (most recent/starred) result = results[0] decoded_path.append({ 'node_id': node_id, 'name': result['name'], 'public_key': result['public_key'], 'device_type': result['device_type'], 'role': result['role'], 'found': True, 'geographic_guess': has_collision, # Mark as guess if collision exists 'collision': has_collision, 'matches': len(results) if has_collision else 1 }) else: decoded_path.append({ 'node_id': node_id, 'name': None, 'found': False }) except Exception as e: self.logger.error(f"Error decoding path: {e}") return [] finally: if conn: try: conn.close() except: pass return decoded_path def run(self, host='127.0.0.1', port=8080, debug=False): """Run the modern web viewer""" self.logger.info(f"Starting modern web viewer on {host}:{port}") try: self.socketio.run( self.app, host=host, port=port, debug=debug, allow_unsafe_werkzeug=True ) except Exception as e: self.logger.error(f"Error running web viewer: {e}") raise def main(): """Entry point for the meshcore-viewer command""" import argparse parser = argparse.ArgumentParser(description='MeshCore Bot Data Viewer') parser.add_argument('--host', default='127.0.0.1', help='Host to bind to') parser.add_argument('--port', type=int, default=8080, help='Port to bind to') parser.add_argument('--debug', action='store_true', help='Enable debug mode') parser.add_argument( "--config", default="config.ini", help="Path to configuration file (default: config.ini)", ) args = parser.parse_args() viewer = BotDataViewer(config_path=args.config) viewer.run(host=args.host, port=args.port, debug=args.debug) if __name__ == '__main__': main()