diff --git a/backup_database.py b/backup_database.py
index 414ebc7..23569cf 100755
--- a/backup_database.py
+++ b/backup_database.py
@@ -4,9 +4,8 @@ Database Backup Script for MeshCore Bot
Creates timestamped backups of all SQLite database files
"""
-import sqlite3
-import shutil
import os
+import sqlite3
import sys
from contextlib import closing
from datetime import datetime
@@ -25,36 +24,36 @@ def backup_database(db_path, backup_dir="backups"):
Path to the backup file if successful, None otherwise
"""
db_path = Path(db_path)
-
+
if not db_path.exists():
print(f"Warning: Database file {db_path} does not exist, skipping...")
return None
-
+
# Create backup directory if it doesn't exist
backup_path = Path(backup_dir)
backup_path.mkdir(exist_ok=True)
-
+
# Generate timestamp string
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
-
+
# Create backup filename with timestamp
db_name = db_path.stem
backup_filename = f"{db_name}_{timestamp}.db"
backup_file = backup_path / backup_filename
-
+
try:
# Use SQLite backup API for proper backup (handles WAL files correctly)
with closing(sqlite3.connect(str(db_path))) as source_conn, closing(sqlite3.connect(str(backup_file))) as backup_conn:
# Perform the backup
source_conn.backup(backup_conn)
-
+
# Get file size for reporting
file_size = backup_file.stat().st_size
file_size_mb = file_size / (1024 * 1024)
-
+
print(f"✓ Backed up {db_path.name} -> {backup_file.name} ({file_size_mb:.2f} MB)")
return str(backup_file)
-
+
except sqlite3.Error as e:
print(f"✗ Error backing up {db_path.name}: {e}")
# Clean up failed backup file
@@ -79,25 +78,25 @@ def check_database_status(db_path):
Tuple of (is_used, table_count, file_size_mb)
"""
db_path = Path(db_path)
-
+
if not db_path.exists():
return (False, 0, 0.0)
-
+
file_size_mb = db_path.stat().st_size / (1024 * 1024)
-
+
try:
conn = sqlite3.connect(str(db_path))
with closing(conn):
cursor = conn.cursor()
-
+
# Get list of tables
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
table_count = len(tables)
-
+
# Consider database used if it has tables or is larger than 1KB
is_used = table_count > 0 or file_size_mb > 0.001
-
+
return (is_used, table_count, file_size_mb)
except sqlite3.Error:
return (False, 0, file_size_mb)
@@ -116,13 +115,13 @@ def find_database_files(root_dir=".", check_usage=True):
"""
root = Path(root_dir)
db_files = []
-
+
# Find all .db files in root directory (not in subdirectories)
for db_file in root.glob("*.db"):
# Skip WAL and SHM files (they're not standalone databases)
if db_file.suffix == ".db":
db_files.append(db_file)
-
+
# Sort by priority: meshcore_bot.db first (main database), then others
def sort_key(db_file):
name = db_file.name.lower()
@@ -132,7 +131,7 @@ def find_database_files(root_dir=".", check_usage=True):
return (1, name)
else:
return (2, name)
-
+
return sorted(db_files, key=sort_key)
@@ -141,75 +140,75 @@ def main():
# Get script directory (project root)
script_dir = Path(__file__).parent.absolute()
os.chdir(script_dir)
-
+
print("MeshCore Bot Database Backup")
print("=" * 50)
print(f"Working directory: {script_dir}")
print()
-
+
# Find all database files
db_files = find_database_files(script_dir)
-
+
if not db_files:
print("No database files found in the project root.")
return 1
-
+
print(f"Found {len(db_files)} database file(s):")
-
+
# Check which databases are actually used
active_databases = []
inactive_databases = []
-
+
for db_file in db_files:
is_used, table_count, file_size = check_database_status(db_file)
status = "✓ ACTIVE" if is_used else "○ EMPTY/UNUSED"
print(f" {status}: {db_file.name} ({table_count} tables, {file_size:.2f} MB)")
-
+
if is_used:
active_databases.append(db_file)
else:
inactive_databases.append(db_file)
-
+
print()
-
+
if not active_databases:
print("No active databases found. Nothing to backup.")
return 0
-
+
if inactive_databases:
print(f"Note: {len(inactive_databases)} database(s) appear to be empty/unused and will be skipped.")
print()
-
+
# Create backups (only for active databases)
backup_dir = script_dir / "backups"
successful_backups = []
failed_backups = []
-
+
for db_file in active_databases:
result = backup_database(db_file, backup_dir)
if result:
successful_backups.append(result)
else:
failed_backups.append(db_file.name)
-
+
print()
print("=" * 50)
- print(f"Backup Summary:")
+ print("Backup Summary:")
print(f" Successful: {len(successful_backups)}")
print(f" Failed: {len(failed_backups)}")
-
+
if successful_backups:
print(f"\nBackups saved to: {backup_dir}")
print("\nRecent backups:")
for backup in successful_backups:
print(f" - {Path(backup).name}")
-
+
if failed_backups:
- print(f"\nFailed to backup:")
+ print("\nFailed to backup:")
for db in failed_backups:
print(f" - {db}")
return 1
-
+
return 0
diff --git a/generate_website.py b/generate_website.py
index 85aa4f3..c9fabd1 100755
--- a/generate_website.py
+++ b/generate_website.py
@@ -4,27 +4,26 @@ Generate Command Reference Website
Creates a single-page HTML website with bot introduction and command reference
"""
+import argparse
import configparser
-import sqlite3
-import os
import html
import logging
+import os
+import sqlite3
import sys
-import argparse
-from pathlib import Path
-from typing import Dict, List, Tuple, Optional, Any
from collections import defaultdict
from contextlib import closing
+from typing import Any, Optional
# Import bot modules with error handling
try:
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-
- from modules.plugin_loader import PluginLoader
- from modules.db_manager import DBManager
- from modules.utils import resolve_path
+
from modules.config_validation import strip_optional_quotes
+ from modules.db_manager import DBManager
+ from modules.plugin_loader import PluginLoader
+ from modules.utils import resolve_path
except ImportError as e:
print("Error: Missing required dependencies.")
print(f"Details: {e}")
@@ -38,19 +37,19 @@ except ImportError as e:
class MinimalBot:
"""Minimal bot mock for plugin loading without full bot initialization"""
-
+
def __init__(self, config, logger, db_manager=None):
self.config = config
self.logger = logger
self.db_manager = db_manager
-
+
# Dummy translator
class DummyTranslator:
def translate(self, key, **kwargs):
return key
def get_value(self, key):
return None
-
+
self.translator = DummyTranslator()
self.command_manager = None # Will be set after plugin loading
@@ -933,7 +932,7 @@ def get_bot_name(config: configparser.ConfigParser) -> str:
return config.get('Bot', 'bot_name', fallback='MeshCore Bot')
-def get_admin_commands(config: configparser.ConfigParser) -> List[str]:
+def get_admin_commands(config: configparser.ConfigParser) -> list[str]:
"""Extract admin commands from config"""
if not config.has_section('Admin_ACL'):
return []
@@ -949,7 +948,7 @@ def get_website_intro(config: configparser.ConfigParser) -> str:
intro = config.get('Website', 'introduction_text', fallback='')
if intro:
return intro
-
+
# Default introduction (first person from bot's perspective)
bot_name = get_bot_name(config)
return f"Hi, I'm {bot_name}! I provide various commands to help you interact with the mesh network. Use the commands below to get started."
@@ -961,14 +960,14 @@ def get_website_title(config: configparser.ConfigParser) -> str:
title = config.get('Website', 'website_title', fallback='')
if title:
return title
-
+
bot_name = get_bot_name(config)
return f"{bot_name} - Command Reference"
-def load_channels_from_config(config: configparser.ConfigParser) -> Dict[str, Dict[str, str]]:
+def load_channels_from_config(config: configparser.ConfigParser) -> dict[str, dict[str, str]]:
"""Load channels from Channels_List section, grouped by category
-
+
Returns:
Dict with structure: {
'general': {'#channel': 'description', ...},
@@ -977,25 +976,25 @@ def load_channels_from_config(config: configparser.ConfigParser) -> Dict[str, Di
}
"""
channels = {'general': {}}
-
+
if not config.has_section('Channels_List'):
return channels
-
+
for key, description in config.items('Channels_List'):
key = key.strip()
description = description.strip()
-
+
if not key or not description:
continue
-
+
# Check if it's a categorized channel (has dot notation)
if '.' in key:
category = key.split('.')[0]
channel_name = key.split('.', 1)[1]
-
+
if category not in channels:
channels[category] = {}
-
+
# Add # prefix if not present
display_name = channel_name if channel_name.startswith('#') else f"#{channel_name}"
channels[category][display_name] = description
@@ -1003,7 +1002,7 @@ def load_channels_from_config(config: configparser.ConfigParser) -> Dict[str, Di
# General channel (no category)
display_name = key if key.startswith('#') else f"#{key}"
channels['general'][display_name] = description
-
+
# Remove empty categories
return {cat: chans for cat, chans in channels.items() if chans}
@@ -1016,36 +1015,36 @@ def get_database_path(config: configparser.ConfigParser, bot_root: str) -> Optio
return None
-def get_command_popularity(db_path: Optional[str], commands: Dict[str, Any]) -> Dict[str, int]:
+def get_command_popularity(db_path: Optional[str], commands: dict[str, Any]) -> dict[str, int]:
"""Get command usage counts from database"""
popularity = defaultdict(int)
-
+
if not db_path or not os.path.exists(db_path):
return popularity
-
+
try:
with closing(sqlite3.connect(db_path)) as conn:
cursor = conn.cursor()
-
+
# Check if command_stats table exists
cursor.execute("""
- SELECT name FROM sqlite_master
+ SELECT name FROM sqlite_master
WHERE type='table' AND name='command_stats'
""")
if not cursor.fetchone():
return popularity
-
+
# Query command usage
cursor.execute("""
- SELECT command_name, COUNT(*) as count
- FROM command_stats
+ SELECT command_name, COUNT(*) as count
+ FROM command_stats
GROUP BY command_name
""")
-
+
for row in cursor.fetchall():
command_name = row[0]
count = row[1]
-
+
# Map to primary command name if it's a keyword/alias
primary_name = command_name
for cmd_name, cmd_instance in commands.items():
@@ -1055,47 +1054,47 @@ def get_command_popularity(db_path: Optional[str], commands: Dict[str, Any]) ->
if hasattr(cmd_instance, 'name') and cmd_instance.name == command_name:
primary_name = cmd_instance.name
break
-
+
popularity[primary_name] += count
except Exception as e:
logging.warning(f"Could not query command popularity: {e}")
-
+
return popularity
-def filter_commands(commands: Dict[str, Any], admin_commands: List[str]) -> Dict[str, Any]:
+def filter_commands(commands: dict[str, Any], admin_commands: list[str]) -> dict[str, Any]:
"""Filter out admin and hidden commands"""
filtered = {}
-
+
# Categories to exclude from public reference
excluded_categories = {'hidden', 'admin', 'system', 'management', 'special'}
-
+
for cmd_name, cmd_instance in commands.items():
# Skip commands in excluded categories
if hasattr(cmd_instance, 'category') and cmd_instance.category in excluded_categories:
continue
-
+
# Get primary command name
primary_name = cmd_instance.name if hasattr(cmd_instance, 'name') else cmd_name
-
+
# Skip admin commands by name (check both dict key and primary name)
if cmd_name in admin_commands or primary_name in admin_commands:
continue
-
+
# Skip commands that require admin access
if hasattr(cmd_instance, 'requires_admin_access') and cmd_instance.requires_admin_access():
continue
-
+
# Skip commands with no keywords (automatic/system commands)
if hasattr(cmd_instance, 'keywords') and not cmd_instance.keywords:
continue
-
+
filtered[cmd_name] = cmd_instance
-
+
return filtered
-def get_default_command_order() -> List[str]:
+def get_default_command_order() -> list[str]:
"""Get default command ordering when no stats available"""
return [
'help', 'ping', 'test', # Basic
@@ -1105,31 +1104,31 @@ def get_default_command_order() -> List[str]:
]
-def sort_commands_by_popularity(commands: Dict[str, Any], popularity: Dict[str, int]) -> List[Tuple[str, Any]]:
+def sort_commands_by_popularity(commands: dict[str, Any], popularity: dict[str, int]) -> list[tuple[str, Any]]:
"""Sort commands by popularity, with fallback to default order"""
default_order = get_default_command_order()
-
+
# Create list of (name, instance, priority) tuples
command_list = []
for cmd_name, cmd_instance in commands.items():
primary_name = cmd_instance.name if hasattr(cmd_instance, 'name') else cmd_name
-
+
# Get popularity count
count = popularity.get(primary_name, 0)
-
+
# Get default priority (lower is better)
try:
default_priority = default_order.index(primary_name)
except ValueError:
default_priority = 999 # Not in default order
-
+
# Priority: popularity count (descending), then default order, then alphabetical
priority = (-count, default_priority, primary_name.lower())
command_list.append((priority, cmd_name, cmd_instance))
-
+
# Sort by priority
command_list.sort(key=lambda x: x[0])
-
+
# Return (name, instance) tuples
return [(name, instance) for _, name, instance in command_list]
@@ -1139,11 +1138,11 @@ def escape_html(text: str) -> str:
return html.escape(str(text))
-def get_channel_info(cmd_instance: Any, monitor_channels: List[str]) -> Optional[str]:
+def get_channel_info(cmd_instance: Any, monitor_channels: list[str]) -> Optional[str]:
"""Get channel restriction information for a command"""
allowed_channels = getattr(cmd_instance, 'allowed_channels', None)
requires_dm = getattr(cmd_instance, 'requires_dm', False)
-
+
# If command has specific allowed channels configured
if allowed_channels is not None:
if allowed_channels == []:
@@ -1162,25 +1161,25 @@ def get_channel_info(cmd_instance: Any, monitor_channels: List[str]) -> Optional
return f"Channel: {', '.join(formatted_channels)}"
else:
return f"Channels: {', '.join(formatted_channels)}"
-
+
# If command requires DM and has no channel override, it's DM only
if requires_dm:
return "DM only"
-
+
# No restrictions - works in all monitored channels (don't show anything)
return None
-def format_monitor_channels(monitor_channels: List[str], html: bool = False) -> str:
+def format_monitor_channels(monitor_channels: list[str], html: bool = False) -> str:
"""Format monitor channels for display
-
+
Args:
monitor_channels: List of channel names
html: If True, wrap channel names in span tags for highlighting
"""
if not monitor_channels:
return ""
-
+
formatted = []
for ch in monitor_channels:
ch = ch.strip()
@@ -1188,13 +1187,13 @@ def format_monitor_channels(monitor_channels: List[str], html: bool = False) ->
channel_name = f"#{ch}"
else:
channel_name = ch
-
+
if html:
# Wrap in span for inline highlighting
formatted.append(f'{escape_html(channel_name)}')
else:
formatted.append(channel_name)
-
+
if len(formatted) == 1:
return formatted[0]
elif len(formatted) == 2:
@@ -1203,27 +1202,27 @@ def format_monitor_channels(monitor_channels: List[str], html: bool = False) ->
return ", ".join(formatted[:-1]) + f", or {formatted[-1]}"
-def generate_html(bot_name: str, title: str, introduction: str, commands: List[Tuple[str, Any]], monitor_channels: List[str] = None, channels_data: Dict[str, Dict[str, str]] = None, style: str = 'default') -> str:
+def generate_html(bot_name: str, title: str, introduction: str, commands: list[tuple[str, Any]], monitor_channels: list[str] = None, channels_data: dict[str, dict[str, str]] = None, style: str = 'default') -> str:
"""Generate the HTML content"""
-
+
if monitor_channels is None:
monitor_channels = []
-
+
if channels_data is None:
channels_data = {}
-
+
# Format monitor channels message to append to introduction
channels_suffix = ""
if monitor_channels:
formatted_channels = format_monitor_channels(monitor_channels, html=True)
channels_suffix = f" I'll answer you if you send a message in {formatted_channels}."
-
+
# Group commands by category
categories = defaultdict(list)
for cmd_name, cmd_instance in commands:
category = getattr(cmd_instance, 'category', 'general')
categories[category].append((cmd_name, cmd_instance))
-
+
# Category display names
category_names = {
'basic': 'Basic Commands',
@@ -1239,46 +1238,46 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
'special': 'Special Commands',
'general': 'General Commands',
}
-
+
# Build navigation sidebar items
nav_items = []
-
+
# Add command categories to nav (basic first, then alphabetically)
sorted_categories = sorted(categories.keys())
if 'basic' in sorted_categories:
sorted_categories.remove('basic')
sorted_categories.insert(0, 'basic')
-
+
for category in sorted_categories:
category_display = category_names.get(category, category.title().replace('_', ' '))
category_id = category.lower().replace('_', '-').replace(' ', '-')
nav_items.append(('commands', category_display, f'commands-{category_id}'))
-
+
# Add channels section to nav if available
if channels_data:
nav_items.append(('channels', 'Available Channels', 'channels'))
-
+
# Add channel subcategories
- sorted_channel_categories = ['general'] + sorted([c for c in channels_data.keys() if c != 'general'])
+ sorted_channel_categories = ['general'] + sorted([c for c in channels_data if c != 'general'])
for category in sorted_channel_categories:
if category not in channels_data or not channels_data[category]:
continue
category_display = category.title().replace('_', ' ') if category != 'general' else 'General Channels'
category_id = category.lower().replace('_', '-').replace(' ', '-')
nav_items.append(('channels-sub', category_display, f'channels-{category_id}'))
-
+
# Build navigation sidebar HTML
nav_html = '\n'
-
+
# Build command HTML (basic first, then alphabetically)
commands_html = ""
sorted_command_categories = sorted(categories.keys())
if 'basic' in sorted_command_categories:
sorted_command_categories.remove('basic')
sorted_command_categories.insert(0, 'basic')
-
+
for category in sorted_command_categories:
category_commands = categories[category]
category_display = category_names.get(category, category.title().replace('_', ' '))
# Create anchor ID from category (lowercase, replace spaces with hyphens)
category_id = category.lower().replace('_', '-').replace(' ', '-')
-
+
commands_html += f'
\n'
commands_html += f'
\n'
- commands_html += f'
\n'
-
+ commands_html += '
\n'
+
for cmd_name, cmd_instance in category_commands:
primary_name = cmd_instance.name if hasattr(cmd_instance, 'name') else cmd_name
keywords = getattr(cmd_instance, 'keywords', [])
-
+
# Filter out the primary name from keywords to avoid duplication
aliases = [k for k in keywords if k.lower() != primary_name.lower()]
-
+
# Get channel restriction info
channel_info = get_channel_info(cmd_instance, monitor_channels)
-
+
# Get usage information including usage syntax, examples, parameters, and sub-commands
try:
usage_info = cmd_instance.get_usage_info()
usage_syntax = usage_info.get('usage', '')
- examples = usage_info.get('examples', [])
+ usage_info.get('examples', [])
parameters = usage_info.get('parameters', [])
subcommands = usage_info.get('subcommands', [])
# Use short_description for website if available, otherwise fallback to description
@@ -1335,77 +1334,76 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
description = short_desc if short_desc else usage_info.get('description', 'No description available')
except Exception:
usage_syntax = ''
- examples = []
parameters = []
subcommands = []
description = getattr(cmd_instance, 'description', 'No description available')
-
- commands_html += f'
\n'
- commands_html += f'
\n'
+
+ commands_html += '
\n'
+ commands_html += '
\n'
commands_html += f'
{escape_html(primary_name)}
\n'
if aliases:
- commands_html += f'
\n'
+ commands_html += '
\n'
# Show first 5 aliases
visible_aliases = aliases[:5]
hidden_aliases = aliases[5:]
-
+
for alias in visible_aliases:
commands_html += f' {escape_html(alias)}\n'
-
+
if hidden_aliases:
# Store hidden aliases in data attribute and create expandable badge
hidden_aliases_json = escape_html(','.join(hidden_aliases))
commands_html += f' +{len(hidden_aliases)} more\n'
-
- commands_html += f'
\n'
- commands_html += f'
\n'
+
+ commands_html += '
\n'
+ commands_html += '
\n'
commands_html += f'
{escape_html(description)}
\n'
-
+
# Render usage, examples, parameters, subcommands
try:
-
+
# Render usage syntax
if usage_syntax:
commands_html += f'
{escape_html(usage_syntax)}
\n'
-
+
# Render parameters
if parameters:
- commands_html += f'
\n'
- commands_html += f' \n'
+ commands_html += '
\n'
+ commands_html += ' \n'
for param in parameters:
param_name = param.get('name', '')
param_desc = param.get('description', '')
if param_name and param_desc:
- commands_html += f'
\n'
+ commands_html += '
\n'
commands_html += f' {escape_html(param_name)}\n'
commands_html += f' {escape_html(param_desc)}\n'
- commands_html += f'
\n'
- commands_html += f'
\n'
-
+ commands_html += '
\n'
+ commands_html += '
\n'
+
# Render subcommands
if subcommands:
- commands_html += f'
\n'
- commands_html += f'
Sub-commands:
\n'
+ commands_html += '
\n'
+ commands_html += '
Sub-commands:
\n'
for subcmd in subcommands:
subcmd_name = subcmd.get('name', '')
subcmd_desc = subcmd.get('description', '')
if subcmd_name and subcmd_desc:
- commands_html += f'
\n'
+ commands_html += '
\n'
commands_html += f' {escape_html(subcmd_name)}\n'
commands_html += f' {escape_html(subcmd_desc)}\n'
- commands_html += f'
\n'
- commands_html += f'
\n'
- except Exception as e:
+ commands_html += '
\n'
+ commands_html += '
\n'
+ except Exception:
# Silently fail if get_usage_info() is not available or fails
pass
-
+
if channel_info:
commands_html += f'
{escape_html(channel_info)}
\n'
- commands_html += f'
\n'
-
- commands_html += f'
\n'
- commands_html += f'
\n'
-
+ commands_html += '
\n'
+
+ commands_html += '
\n'
+ commands_html += '\n'
+
# Build channels HTML if channels are available
channels_html = ""
if channels_data:
@@ -1414,31 +1412,31 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
channels_html += ' These are semi-public channels that are in use on our local mesh! Join them to connect with others with common interests!
To add these channels to your client, click on the three dot menu and select "Add Channel".
\n'
# Sort categories: general first, then alphabetically
- sorted_categories = ['general'] + sorted([c for c in channels_data.keys() if c != 'general'])
-
+ sorted_categories = ['general'] + sorted([c for c in channels_data if c != 'general'])
+
for category in sorted_categories:
if category not in channels_data or not channels_data[category]:
continue
-
+
category_display = category.title().replace('_', ' ') if category != 'general' else 'General Channels'
category_id = category.lower().replace('_', '-').replace(' ', '-')
channels_html += f' \n'
channels_html += f'
\n'
- channels_html += f'
\n'
-
+ channels_html += '
\n'
+
# Sort channels alphabetically
sorted_channels = sorted(channels_data[category].items())
for channel_name, description in sorted_channels:
- channels_html += f'
\n'
+ channels_html += '
\n'
channels_html += f'
{escape_html(channel_name)}
\n'
channels_html += f'
{escape_html(description)}
\n'
- channels_html += f'
\n'
-
- channels_html += f'
\n'
- channels_html += f'
\n'
-
+ channels_html += '
\n'
+
+ channels_html += '
\n'
+ channels_html += ' \n'
+
channels_html += '\n'
-
+
html_content = f"""
@@ -1452,17 +1450,17 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
:root {{
{STYLES[style]['css_vars']}
}}
-
+
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
-
+
html {{
scroll-behavior: smooth;
}}
-
+
body {{
font-family: 'Outfit', sans-serif;
background: var(--bg-primary);
@@ -1471,31 +1469,31 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
overflow-x: hidden;
line-height: 1.6;
}}
-
+
/* Atmospheric background */
.atmosphere {{
position: fixed;
inset: 0;
- background:
+ background:
radial-gradient(ellipse 80% 50% at 20% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 50%),
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(0, 255, 200, 0.05) 0%, transparent 50%),
radial-gradient(ellipse 100% 100% at 50% 50%, var(--bg-primary) 0%, #060a0f 100%);
pointer-events: none;
z-index: -1;
}}
-
+
/* Subtle grid overlay */
.grid-overlay {{
position: fixed;
inset: 0;
- background-image:
+ background-image:
linear-gradient(rgba(255,255,255,0.01) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.01) 1px, transparent 1px);
background-size: 60px 60px;
pointer-events: none;
z-index: -1;
}}
-
+
.container {{
max-width: 1600px;
margin: 0 auto;
@@ -1507,7 +1505,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
grid-template-columns: 280px 1fr;
gap: 3rem;
}}
-
+
.sidebar-nav {{
position: sticky;
top: 2rem;
@@ -1523,7 +1521,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
opacity: 1;
filter: none;
}}
-
+
.mobile-menu-toggle {{
display: none;
position: fixed;
@@ -1544,17 +1542,17 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
}}
-
+
.mobile-menu-toggle:hover {{
background: var(--bg-card-hover);
border-color: var(--accent-cyan);
}}
-
+
.mobile-menu-toggle.active {{
background: var(--bg-card-hover);
border-color: var(--accent-cyan);
}}
-
+
.hamburger {{
width: 24px;
height: 2px;
@@ -1562,19 +1560,19 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
border-radius: 2px;
transition: all 0.3s ease;
}}
-
+
.mobile-menu-toggle.active .hamburger:nth-child(1) {{
transform: rotate(45deg) translate(7px, 7px);
}}
-
+
.mobile-menu-toggle.active .hamburger:nth-child(2) {{
opacity: 0;
}}
-
+
.mobile-menu-toggle.active .hamburger:nth-child(3) {{
transform: rotate(-45deg) translate(7px, -7px);
}}
-
+
.sidebar-overlay {{
display: none;
position: fixed;
@@ -1585,74 +1583,74 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
-webkit-backdrop-filter: none;
pointer-events: none;
}}
-
+
.sidebar-overlay.visible {{
pointer-events: auto;
}}
-
+
@media (max-width: 1200px) {{
.sidebar-overlay.visible {{
/* Don't cover the sidebar area - start overlay after sidebar width */
left: 280px !important;
}}
}}
-
+
@media (min-width: 1201px) {{
.sidebar-overlay {{
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}}
}}
-
+
@media (max-width: 1200px) {{
.sidebar-overlay {{
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
z-index: 1000 !important;
}}
-
+
.sidebar-overlay.visible {{
pointer-events: auto !important;
}}
-
+
/* Ensure sidebar is always clickable above overlay */
.sidebar-nav {{
pointer-events: auto !important;
}}
-
+
.sidebar-nav * {{
pointer-events: auto !important;
}}
}}
-
+
.sidebar-overlay.visible {{
display: block;
}}
-
+
.sidebar-nav::-webkit-scrollbar {{
width: 6px;
}}
-
+
.sidebar-nav::-webkit-scrollbar-track {{
background: var(--bg-secondary);
border-radius: 3px;
}}
-
+
.sidebar-nav::-webkit-scrollbar-thumb {{
background: var(--border-subtle);
border-radius: 3px;
}}
-
+
.sidebar-nav::-webkit-scrollbar-thumb:hover {{
background: rgba(255,255,255,0.15);
}}
-
+
.nav-header {{
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-subtle);
}}
-
+
.nav-header h3 {{
font-size: 1rem;
font-weight: 600;
@@ -1661,13 +1659,13 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
letter-spacing: 0.05em;
font-size: 0.85rem;
}}
-
+
.nav-list {{
list-style: none;
padding: 0;
margin: 0;
}}
-
+
.nav-section-header {{
font-size: 0.75rem;
text-transform: uppercase;
@@ -1678,18 +1676,18 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
margin-bottom: 0.75rem;
padding-left: 0.5rem;
}}
-
+
.nav-section-header:first-child {{
margin-top: 0;
}}
-
+
.nav-sublist {{
list-style: none;
padding: 0;
margin: 0 0 1rem 0;
padding-left: 0.5rem;
}}
-
+
.nav-link {{
display: block;
padding: 0.6rem 0.75rem;
@@ -1702,13 +1700,13 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
cursor: pointer;
-webkit-tap-highlight-color: rgba(0, 255, 200, 0.2);
}}
-
+
.nav-link:hover {{
background: var(--bg-card-hover);
color: var(--accent-cyan);
transform: translateX(4px);
}}
-
+
.nav-link:active,
.nav-link.active {{
background: rgba(0, 255, 200, 0.1);
@@ -1716,26 +1714,26 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
border-left: 3px solid var(--accent-cyan);
padding-left: calc(0.75rem - 3px);
}}
-
+
.nav-sublink {{
padding-left: 1.25rem;
font-size: 0.85rem;
color: var(--text-muted);
}}
-
+
.nav-sublink:hover {{
color: var(--accent-blue);
}}
-
+
.main-content {{
min-width: 0;
}}
-
+
header {{
margin-bottom: 4rem;
padding: 0;
}}
-
+
.header-content {{
background: var(--bg-card);
border-radius: 20px;
@@ -1744,7 +1742,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
position: relative;
overflow: hidden;
}}
-
+
.header-content::before {{
content: '';
position: absolute;
@@ -1754,14 +1752,14 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
height: 3px;
background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan));
}}
-
+
.header-title {{
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}}
-
+
.logo-icon {{
display: flex;
align-items: center;
@@ -1770,7 +1768,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
flex-shrink: 0;
line-height: 1;
}}
-
+
h1 {{
font-size: 3rem;
font-weight: 600;
@@ -1779,7 +1777,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
letter-spacing: -0.02em;
font-family: 'Outfit', sans-serif;
}}
-
+
.intro {{
font-size: 1.1rem;
color: var(--text-secondary);
@@ -1787,16 +1785,16 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
line-height: 1.8;
margin-top: 0.5rem;
}}
-
+
.channel-highlight {{
color: var(--accent-cyan);
font-weight: 500;
}}
-
+
.category-section {{
margin-bottom: 3rem;
}}
-
+
.category-title {{
font-size: 1.75rem;
font-weight: 600;
@@ -1806,7 +1804,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
border-bottom: 1px solid var(--border-subtle);
letter-spacing: -0.01em;
}}
-
+
.category-title .anchor-link,
.channel-category-title .anchor-link {{
color: inherit;
@@ -1814,12 +1812,12 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
position: relative;
display: inline-block;
}}
-
+
.category-title .anchor-link:hover,
.channel-category-title .anchor-link:hover {{
color: var(--accent-cyan);
}}
-
+
.category-title .anchor-link::before {{
content: '#';
position: absolute;
@@ -1829,24 +1827,24 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
font-weight: 400;
transition: opacity 0.2s ease;
}}
-
+
.category-title:hover .anchor-link::before,
.channel-category-title:hover .anchor-link::before {{
opacity: 0.5;
}}
-
+
.category-section,
.channel-category {{
scroll-margin-top: 2rem;
}}
-
+
.commands-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.25rem;
margin-bottom: 2rem;
}}
-
+
.command-card {{
background: var(--bg-card);
border-radius: 16px;
@@ -1856,7 +1854,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
position: relative;
overflow: hidden;
}}
-
+
.command-card::before {{
content: '';
position: absolute;
@@ -1868,24 +1866,24 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
opacity: 0;
transition: opacity 0.3s ease;
}}
-
+
.command-card:hover {{
background: var(--bg-card-hover);
border-color: rgba(255,255,255,0.1);
transform: translateY(-2px);
}}
-
+
.command-card:hover::before {{
opacity: 1;
}}
-
+
.command-header {{
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}}
-
+
.command-name {{
font-size: 1.5rem;
font-weight: 600;
@@ -1893,13 +1891,13 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
margin: 0;
letter-spacing: -0.01em;
}}
-
+
.command-keywords {{
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}}
-
+
.keyword-badge {{
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
@@ -1910,37 +1908,37 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
border: 1px solid rgba(0, 212, 255, 0.2);
font-family: 'JetBrains Mono', monospace;
}}
-
+
.keyword-expand {{
cursor: pointer;
transition: all 0.2s ease;
}}
-
+
.keyword-expand:hover {{
background: rgba(0, 212, 255, 0.2);
border-color: var(--accent-cyan);
transform: scale(1.05);
}}
-
+
.keyword-expand.expanded {{
display: none;
}}
-
+
.keyword-hidden {{
display: none;
}}
-
+
.keyword-hidden.visible {{
display: inline-block;
}}
-
+
.command-description {{
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 0.75rem;
font-size: 0.95rem;
}}
-
+
.command-usage {{
margin: 0.75rem 0;
padding: 0.6rem 0.9rem;
@@ -1948,19 +1946,19 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
border-radius: 8px;
border-left: 3px solid var(--accent-blue);
}}
-
+
.command-usage code {{
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: var(--accent-cyan);
}}
-
+
.command-params {{
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-subtle);
}}
-
+
.params-header {{
font-size: 0.8rem;
font-weight: 600;
@@ -1969,7 +1967,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
text-transform: uppercase;
letter-spacing: 0.05em;
}}
-
+
.param-item {{
display: flex;
gap: 0.5rem;
@@ -1977,7 +1975,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
font-size: 0.85rem;
align-items: flex-start;
}}
-
+
.param-name {{
font-family: 'JetBrains Mono', monospace;
color: var(--accent-orange);
@@ -1985,19 +1983,19 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
min-width: 80px;
flex-shrink: 0;
}}
-
+
.param-desc {{
color: var(--text-secondary);
flex: 1;
line-height: 1.4;
}}
-
+
.command-subcommands {{
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-subtle);
}}
-
+
.subcommands-header {{
font-size: 0.85rem;
font-weight: 600;
@@ -2006,7 +2004,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
text-transform: uppercase;
letter-spacing: 0.05em;
}}
-
+
.subcommand-item {{
display: flex;
gap: 0.75rem;
@@ -2014,7 +2012,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
font-size: 0.9rem;
align-items: flex-start;
}}
-
+
.subcommand-name {{
font-family: 'JetBrains Mono', monospace;
color: var(--accent-cyan);
@@ -2022,13 +2020,13 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
min-width: 100px;
flex-shrink: 0;
}}
-
+
.subcommand-desc {{
color: var(--text-secondary);
flex: 1;
line-height: 1.5;
}}
-
+
.command-channels {{
color: var(--accent-cyan);
font-size: 0.85rem;
@@ -2040,18 +2038,18 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
}}
-
+
.channels-intro {{
color: var(--text-secondary);
font-size: 1rem;
margin-bottom: 2rem;
line-height: 1.7;
}}
-
+
.channel-category {{
margin-bottom: 2.5rem;
}}
-
+
.channel-category-title {{
font-size: 1.25rem;
font-weight: 600;
@@ -2059,7 +2057,7 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
margin-bottom: 1rem;
letter-spacing: -0.01em;
}}
-
+
.channel-category-title .anchor-link::before {{
content: '#';
position: absolute;
@@ -2069,13 +2067,13 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
font-weight: 400;
transition: opacity 0.2s ease;
}}
-
+
.channels-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}}
-
+
.channel-card {{
background: var(--bg-card);
border-radius: 12px;
@@ -2083,13 +2081,13 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
border: 1px solid var(--border-subtle);
transition: all 0.3s ease;
}}
-
+
.channel-card:hover {{
background: var(--bg-card-hover);
border-color: rgba(0, 212, 255, 0.3);
transform: translateY(-2px);
}}
-
+
.channel-name {{
font-family: 'JetBrains Mono', monospace;
font-size: 1.1rem;
@@ -2097,13 +2095,13 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
color: var(--accent-cyan);
margin-bottom: 0.5rem;
}}
-
+
.channel-description {{
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.6;
}}
-
+
footer {{
text-align: center;
margin-top: 4rem;
@@ -2112,28 +2110,28 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
font-size: 0.9rem;
border-top: 1px solid var(--border-subtle);
}}
-
+
@media (max-width: 1200px) {{
.commands-grid {{
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}}
}}
-
+
@media (max-width: 1200px) {{
/* Disable all backdrop filters on mobile */
* {{
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}}
-
+
.container {{
grid-template-columns: 1fr !important;
}}
-
+
.mobile-menu-toggle {{
display: flex;
}}
-
+
.sidebar-nav {{
position: fixed !important;
top: 0 !important;
@@ -2169,11 +2167,11 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
-webkit-transform: translateZ(0) !important;
transform: translateZ(0) !important;
}}
-
+
.sidebar-nav.open {{
left: 0 !important;
}}
-
+
.sidebar-nav * {{
opacity: 1 !important;
filter: none !important;
@@ -2184,13 +2182,13 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
-moz-osx-font-smoothing: grayscale !important;
pointer-events: auto !important;
}}
-
+
.sidebar-nav .nav-header h3 {{
color: #e8edf4 !important;
opacity: 1 !important;
text-shadow: none !important;
}}
-
+
.sidebar-nav .nav-link {{
color: #8892a4 !important;
opacity: 1 !important;
@@ -2199,24 +2197,24 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
cursor: pointer !important;
-webkit-tap-highlight-color: rgba(0, 255, 200, 0.2) !important;
}}
-
+
.sidebar-nav .nav-link:hover,
.sidebar-nav .nav-link:active {{
color: #00ffc8 !important;
opacity: 1 !important;
}}
-
+
.sidebar-nav .nav-section-header {{
color: #8892a4 !important;
opacity: 1 !important;
text-shadow: none !important;
}}
-
+
.main-content {{
margin-top: 0;
}}
}}
-
+
@media (min-width: 1201px) {{
.sidebar-nav {{
position: sticky !important;
@@ -2241,46 +2239,46 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
visibility: visible !important;
}}
}}
-
+
@media (max-width: 768px) {{
.container {{
padding: 2rem 1rem;
}}
-
+
.header-content {{
padding: 2rem 1.5rem;
}}
-
+
.header-title {{
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}}
-
+
.logo-icon {{
width: 48px;
height: 48px;
font-size: 1.5rem;
}}
-
+
h1 {{
font-size: 2.25rem;
}}
-
+
.commands-grid {{
grid-template-columns: 1fr;
}}
-
+
.sidebar-nav {{
padding: 1rem;
width: calc(100vw - 3rem);
left: calc(-100vw + 3rem);
}}
-
+
.sidebar-nav.open {{
left: 0;
}}
-
+
.mobile-menu-toggle {{
top: 1rem;
right: 1rem;
@@ -2294,18 +2292,18 @@ def generate_html(bot_name: str, title: str, introduction: str, commands: List[T
-
+
-
+
-
+
{nav_html}
-
+
-
+
{commands_html}
{channels_html}
-
+
-
+
"""
-
+
return html_content
@@ -2425,7 +2423,7 @@ def list_styles():
print("Available styles:\n")
# Calculate max name length for alignment
- max_len = max(len(name) for name in STYLES.keys())
+ max_len = max(len(name) for name in STYLES)
for style_name, style_info in STYLES.items():
padding = ' ' * (max_len - len(style_name))
@@ -2491,7 +2489,7 @@ def generate_samples(config_file):
# Generate a page for each style
generated_files = []
- for style_name in STYLES.keys():
+ for style_name in STYLES:
logger.info(f"Generating {style_name}.html...")
html_content = generate_html(
@@ -2588,7 +2586,7 @@ def generate_samples(config_file):
"""
- for style_name in STYLES.keys():
+ for style_name in STYLES:
style_info = STYLES[style_name]
index_html += f"""
{escape_html(style_info['name'])}
@@ -2608,7 +2606,7 @@ def generate_samples(config_file):
logger.info(f" ✓ {index_path}")
logger.info(f"\nGenerated {len(generated_files)} style samples + index.html in {output_dir}")
print(f"\n✓ Sample files generated in {output_dir}/")
- print(f" - index.html (style browser)")
+ print(" - index.html (style browser)")
for style_name in generated_files:
print(f" - {style_name}.html")
@@ -2663,38 +2661,38 @@ def main():
config_file = args.config
style = args.style
-
+
try:
# Read config
logger.info(f"Reading config from {config_file}")
config = read_config(config_file)
-
+
# Get bot root directory
bot_root = os.path.dirname(os.path.abspath(config_file))
if not bot_root:
bot_root = os.getcwd()
-
+
# Get bot information
bot_name = get_bot_name(config)
admin_commands = get_admin_commands(config)
introduction = get_website_intro(config)
title = get_website_title(config)
-
+
# Get monitor channels for channel restriction display (quoted or unquoted)
monitor_channels_str = strip_optional_quotes(config.get('Channels', 'monitor_channels', fallback=''))
monitor_channels = [ch.strip() for ch in monitor_channels_str.split(',') if ch.strip()]
-
+
# Load channels from Channels_List section
channels_data = load_channels_from_config(config)
logger.info(f"Loaded {sum(len(chans) for chans in channels_data.values())} channels from {len(channels_data)} categories")
-
+
logger.info(f"Bot name: {bot_name}")
logger.info(f"Admin commands: {admin_commands}")
logger.info(f"Monitor channels: {monitor_channels}")
-
+
# Setup minimal bot for plugin loading
minimal_bot = MinimalBot(config, logger)
-
+
# Initialize database manager if database exists
db_path = get_database_path(config, bot_root)
if db_path and os.path.exists(db_path):
@@ -2707,21 +2705,21 @@ def main():
else:
logger.info("No database found, using default command ordering")
minimal_bot.db_manager = None
-
+
# Load plugins
logger.info("Loading command plugins...")
plugin_loader = PluginLoader(minimal_bot)
commands = plugin_loader.load_all_plugins()
logger.info(f"Loaded {len(commands)} commands")
-
+
# Filter out admin and hidden commands
filtered_commands = filter_commands(commands, admin_commands)
logger.info(f"Filtered to {len(filtered_commands)} public commands")
-
+
# Log which commands are included
included_names = sorted([cmd.name if hasattr(cmd, 'name') else name for name, cmd in filtered_commands.items()])
logger.info(f"Included commands: {', '.join(included_names)}")
-
+
# Log which commands were excluded (for debugging)
excluded = []
for cmd_name, cmd_instance in commands.items():
@@ -2731,32 +2729,32 @@ def main():
excluded.append(f"{primary_name} ({category})")
if excluded:
logger.debug(f"Excluded commands: {', '.join(excluded)}")
-
+
# Get command popularity
popularity = get_command_popularity(db_path, filtered_commands)
-
+
# Sort commands by popularity
sorted_commands = sort_commands_by_popularity(filtered_commands, popularity)
-
+
# Generate HTML
logger.info("Generating HTML...")
html_content = generate_html(bot_name, title, introduction, sorted_commands, monitor_channels, channels_data, style)
-
+
# Create website directory
website_dir = os.path.join(bot_root, "website")
os.makedirs(website_dir, exist_ok=True)
-
+
# Write HTML file
output_file = os.path.join(website_dir, "index.html")
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html_content)
-
+
logger.info(f"Website generated successfully: {output_file}")
print(f"\n✓ Website generated: {output_file}")
print(f" Bot: {bot_name}")
print(f" Commands: {len(sorted_commands)}")
print(f" Output: {output_file}")
-
+
except Exception as e:
logger.error(f"Error generating website: {e}", exc_info=True)
sys.exit(1)
diff --git a/meshcore_bot.py b/meshcore_bot.py
index 017004c..9cf60ff 100644
--- a/meshcore_bot.py
+++ b/meshcore_bot.py
@@ -56,7 +56,6 @@ def main():
if args.validate_config:
from modules.config_validation import (
SEVERITY_ERROR,
- SEVERITY_INFO,
SEVERITY_WARNING,
validate_config,
)
@@ -74,7 +73,7 @@ def main():
from modules.core import MeshCoreBot
bot = MeshCoreBot(config_file=args.config)
-
+
# Use asyncio.run() which handles KeyboardInterrupt properly
# For SIGTERM, we'll handle it in the async context
async def run_bot():
@@ -149,7 +148,7 @@ def main():
await bot.start()
finally:
await bot.stop()
-
+
try:
asyncio.run(run_bot())
except KeyboardInterrupt:
diff --git a/migrate_webviewer_db.py b/migrate_webviewer_db.py
index ac00626..ca68410 100644
--- a/migrate_webviewer_db.py
+++ b/migrate_webviewer_db.py
@@ -57,7 +57,7 @@ def main() -> int:
)
if cur.fetchone() is None:
conn.execute("DETACH DATABASE src")
- print(f"Source database has no packet_stream table; nothing to migrate.", file=sys.stderr)
+ print("Source database has no packet_stream table; nothing to migrate.", file=sys.stderr)
return 0
# Copy rows from source; skip ids that already exist in target (INSERT OR IGNORE)
diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh
index ccdaadd..3898ad6 100755
--- a/scripts/build-deb.sh
+++ b/scripts/build-deb.sh
@@ -18,6 +18,14 @@ if [[ -z "${VERSION}" ]]; then
grep -Po '(?<=^version = ")([^"]+)' "${PROJECT_ROOT}/pyproject.toml" || echo "0.9.0")"
fi
+# Validate VERSION: must be semver-like (digits and dots only) to prevent
+# shell injection via the unquoted <&2
+ echo " Must match semver format, e.g. 1.2.3 or 1.2.3~rc1" >&2
+ exit 1
+fi
+
PACKAGE_NAME="meshcore-bot"
ARCH="all"
INSTALL_ROOT="/opt/meshcore-bot"
diff --git a/scripts/config_tui.py b/scripts/config_tui.py
index a75cb02..a227367 100755
--- a/scripts/config_tui.py
+++ b/scripts/config_tui.py
@@ -29,13 +29,10 @@ Navigation:
import configparser
import curses
-import os
-import re
import sys
import textwrap
from pathlib import Path
-from typing import Dict, List, Optional, Tuple
-
+from typing import Optional
# ---------------------------------------------------------------------------
# Helpers
@@ -58,20 +55,20 @@ def load_config(path: Path) -> configparser.ConfigParser:
if path.exists():
try:
cfg.read(str(path), encoding="utf-8")
- except (configparser.Error, IOError, OSError, UnicodeDecodeError) as e:
+ except (configparser.Error, OSError, UnicodeDecodeError) as e:
# Return empty config rather than crash; caller may show a warning
cfg._load_error = str(e) # type: ignore[attr-defined]
return cfg
-def load_example_comments(example_path: Path) -> Dict[str, Dict[str, str]]:
+def load_example_comments(example_path: Path) -> dict[str, dict[str, str]]:
"""Parse config.ini.example — return {section: {key: comment_text}}."""
- comments: Dict[str, Dict[str, str]] = {}
+ comments: dict[str, dict[str, str]] = {}
if not example_path.exists():
return comments
try:
current_section = ""
- pending: List[str] = []
+ pending: list[str] = []
with open(example_path, encoding="utf-8", errors="replace") as fh:
for line in fh:
stripped = line.strip()
@@ -88,14 +85,14 @@ def load_example_comments(example_path: Path) -> Dict[str, Dict[str, str]]:
pending = []
else:
pending = []
- except (IOError, OSError, UnicodeDecodeError):
+ except (OSError, UnicodeDecodeError):
pass
return comments
-def load_example_lines(example_path: Path) -> Dict[str, Dict[str, str]]:
+def load_example_lines(example_path: Path) -> dict[str, dict[str, str]]:
"""Return {section: {key: 'full raw line from example'}}."""
- lines: Dict[str, Dict[str, str]] = {}
+ lines: dict[str, dict[str, str]] = {}
if not example_path.exists():
return lines
try:
@@ -110,14 +107,14 @@ def load_example_lines(example_path: Path) -> Dict[str, Dict[str, str]]:
key = stripped.split("=", 1)[0].strip()
if current_section:
lines.setdefault(current_section, {})[key] = stripped
- except (IOError, OSError, UnicodeDecodeError):
+ except (OSError, UnicodeDecodeError):
pass
return lines
-def load_example_keys(example_path: Path) -> Dict[str, Dict[str, str]]:
+def load_example_keys(example_path: Path) -> dict[str, dict[str, str]]:
"""Return {section: {key: default_value}} from config.ini.example."""
- keys: Dict[str, Dict[str, str]] = {}
+ keys: dict[str, dict[str, str]] = {}
if not example_path.exists():
return keys
try:
@@ -129,17 +126,17 @@ def load_example_keys(example_path: Path) -> Dict[str, Dict[str, str]]:
keys[section] = dict(cfg.items(section))
except configparser.Error:
keys[section] = {}
- except (configparser.Error, IOError, OSError, UnicodeDecodeError):
+ except (configparser.Error, OSError, UnicodeDecodeError):
pass
return keys
def validate_config(
cfg: configparser.ConfigParser,
- example_keys: Dict[str, Dict[str, str]],
-) -> List[Tuple[str, str, str]]:
+ example_keys: dict[str, dict[str, str]],
+) -> list[tuple[str, str, str]]:
"""Return list of (severity, section, message) issues."""
- issues: List[Tuple[str, str, str]] = []
+ issues: list[tuple[str, str, str]] = []
try:
required = {
"Connection": {"connection_type"},
@@ -174,10 +171,10 @@ def validate_config(
def migrate_config(
cfg: configparser.ConfigParser,
- example_keys: Dict[str, Dict[str, str]],
- example_comments: Dict[str, Dict[str, str]],
-) -> List[str]:
- changes: List[str] = []
+ example_keys: dict[str, dict[str, str]],
+ example_comments: dict[str, dict[str, str]],
+) -> list[str]:
+ changes: list[str] = []
for section, keys in example_keys.items():
if not cfg.has_section(section):
try:
@@ -201,13 +198,13 @@ def migrate_config(
def save_config(cfg: configparser.ConfigParser, path: Path) -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
- except (IOError, OSError, PermissionError) as e:
- raise IOError(f"Cannot create directory {path.parent}: {e}") from e
+ except (OSError, PermissionError) as e:
+ raise OSError(f"Cannot create directory {path.parent}: {e}") from e
try:
with open(path, "w", encoding="utf-8") as fh:
cfg.write(fh)
- except (IOError, OSError, PermissionError) as e:
- raise IOError(f"Cannot write {path}: {e}") from e
+ except (OSError, PermissionError) as e:
+ raise OSError(f"Cannot write {path}: {e}") from e
# ---------------------------------------------------------------------------
@@ -219,9 +216,9 @@ class TUIState:
self,
cfg: configparser.ConfigParser,
path: Path,
- example_keys: Dict[str, Dict[str, str]],
- example_comments: Dict[str, Dict[str, str]],
- example_lines: Dict[str, Dict[str, str]],
+ example_keys: dict[str, dict[str, str]],
+ example_comments: dict[str, dict[str, str]],
+ example_lines: dict[str, dict[str, str]],
) -> None:
self.cfg = cfg
self.path = path
@@ -229,7 +226,7 @@ class TUIState:
self.example_comments = example_comments
self.example_lines = example_lines
self.dirty = False
- self.sections: List[str] = []
+ self.sections: list[str] = []
try:
self.sections = cfg.sections()
except Exception:
@@ -245,7 +242,7 @@ class TUIState:
return ""
return self.sections[self.section_idx]
- def current_keys(self) -> List[str]:
+ def current_keys(self) -> list[str]:
s = self.current_section()
if not s:
return []
@@ -442,7 +439,7 @@ def draw_hint_line(win, state: TUIState, top: int, left: int, width: int) -> Non
# Overlays
# ---------------------------------------------------------------------------
-def show_overlay(stdscr, title: str, items: List[Tuple[str, str, str]]) -> None:
+def show_overlay(stdscr, title: str, items: list[tuple[str, str, str]]) -> None:
"""Generic scrollable list overlay. Items: (tag, section, message)."""
try:
curses.curs_set(0)
@@ -561,7 +558,7 @@ def show_key_help(stdscr, state: TUIState) -> None:
current_val = "(error reading value)"
default_val = state.example_keys.get(section, {}).get(key, "")
- lines: List[Tuple[str, str, str]] = [
+ lines: list[tuple[str, str, str]] = [
("INFO", "Section", section),
("INFO", "Key", key),
("INFO", "Current value", repr(current_val)),
@@ -1189,7 +1186,7 @@ def main() -> None:
print(f"config.ini not found — creating from {example_path.name}…")
shutil.copy(str(example_path), str(config_path))
print(f"Created {config_path}")
- except (IOError, OSError, shutil.Error) as e:
+ except (OSError, shutil.Error) as e:
print(f"ERROR: Could not create config from example: {e}", file=sys.stderr)
sys.exit(1)
diff --git a/scripts/update_todos.py b/scripts/update_todos.py
index fd612cd..8c76ac8 100644
--- a/scripts/update_todos.py
+++ b/scripts/update_todos.py
@@ -92,7 +92,7 @@ def build_section(todos, today: str) -> str:
continue
lines.append(f"### {emoji.get(marker, '')} {marker}\n")
for rel, lineno, text in sorted(by_marker[marker]):
- label = text if text else f"(no description — see file)"
+ label = text if text else "(no description — see file)"
lines.append(f"- [ ] **`{rel}:{lineno}`** — {label}")
lines.append("")
diff --git a/tests/test_command_manager.py b/tests/test_command_manager.py
index 02e117b..bfa0a46 100644
--- a/tests/test_command_manager.py
+++ b/tests/test_command_manager.py
@@ -41,6 +41,7 @@ def cm_bot(mock_logger):
bot.bot_tx_rate_limiter = Mock()
bot.bot_tx_rate_limiter.wait_for_tx = Mock()
bot.tx_delay_ms = 0
+ bot.is_radio_zombie = False
return bot
@@ -553,6 +554,7 @@ class TestSendChannelMessageRetry:
def _setup_bot(self, cm_bot):
cm_bot.connected = True
+ cm_bot.is_radio_zombie = False
cm_bot.channel_manager = Mock()
cm_bot.channel_manager.get_channel_number = Mock(return_value=2)
cm_bot.meshcore = Mock()
diff --git a/validate_config.py b/validate_config.py
index b94bdd6..8661fa8 100644
--- a/validate_config.py
+++ b/validate_config.py
@@ -11,7 +11,6 @@ import sys
from modules.config_validation import (
SEVERITY_ERROR,
- SEVERITY_INFO,
SEVERITY_WARNING,
validate_config,
)