{escape_html(bot_name)}
')}{channels_suffix}
#!/usr/bin/env python3 """ Generate Command Reference Website Creates a single-page HTML website with bot introduction and command reference """ import configparser import sqlite3 import os import html import logging import sys import argparse from pathlib import Path from typing import Dict, List, Tuple, Optional, Any from collections import defaultdict from contextlib import closing # 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 except ImportError as e: print("Error: Missing required dependencies.") print(f"Details: {e}") print("\nPlease install bot dependencies by running:") print(" pip install -r requirements.txt") print("\nOr install the missing module directly:") if 'pytz' in str(e): print(" pip install pytz") sys.exit(1) 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 # Style definitions for website themes # Each style contains CSS custom property values that will be injected into the :root selector STYLES = { 'default': { 'name': 'Modern Dark', 'description': 'Dark theme with gradients and modern cards (current default)', 'fonts_url': 'https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap', 'css_vars': """--bg-primary: #0a0e14; --bg-secondary: #111820; --bg-card: #151c25; --bg-card-hover: #1a232e; --accent-blue: #00d4ff; --accent-cyan: #00ffc8; --accent-orange: #ff8a00; --accent-purple: #a855f7; --accent-red: #ff4757; --accent-yellow: #ffd700; --text-primary: #e8edf4; --text-secondary: #8892a4; --text-muted: #6b7280; --border-subtle: rgba(255,255,255,0.06); --glow-blue: rgba(0, 212, 255, 0.15); --glow-cyan: rgba(0, 255, 200, 0.1);""" }, 'minimalist': { 'name': 'Minimalist Clean', 'description': 'Light theme with clean typography and whitespace', 'fonts_url': 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap', 'css_vars': """--bg-primary: #ffffff; --bg-secondary: #ffffff; --bg-card: #ffffff; --bg-card-hover: #f8f9fa; --accent-blue: #0052cc; --accent-cyan: #006699; --accent-orange: #d65d0e; --accent-purple: #6b21a8; --accent-red: #b91c1c; --accent-yellow: #a16207; --text-primary: #1a1a1a; --text-secondary: #4a4a4a; --text-muted: #6b6b6b; --border-subtle: rgba(0,0,0,0.15); --glow-blue: rgba(0,82,204,0.1); --glow-cyan: rgba(0,102,153,0.08);""", 'css_overrides': """ /* Pure white background - remove atmospheric effects */ .atmosphere, .grid-overlay { display: none !important; } body { background: #ffffff !important; } /* Ultra-minimal: square everything */ *, *::before, *::after, .container, .intro-section, .sidebar-nav, .sidebar-nav a, .command-card, .command-keyword, .command-usage, .channel-section, .channel-card, button, input { border-radius: 0 !important; } /* Clean Inter font everywhere */ body, h1, h2, h3, h4, h5, h6, p, a, span, div { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important; } /* Remove all shadows */ .command-card, .intro-section, .sidebar-nav, .command-keyword, .command-usage { box-shadow: none !important; } /* Thin, clean borders */ .command-card { border: 1px solid var(--border-subtle) !important; } .intro-section { border: 1px solid var(--border-subtle) !important; } .sidebar-nav { border-right: 1px solid var(--border-subtle) !important; } .command-keyword { border: 1px solid currentColor !important; font-weight: 500; } .command-usage { border-left: 2px solid var(--border-subtle) !important; } /* Minimal hover effects - just background color */ .command-card:hover { box-shadow: none !important; transform: none !important; } .sidebar-nav a:hover { background: var(--bg-card-hover) !important; } /* Typography: lighter weight, more spacing */ h1 { font-weight: 600; letter-spacing: -0.02em; } h2, h3 { font-weight: 500; letter-spacing: -0.01em; } .command-name { font-weight: 600; } p, .command-description { font-weight: 400; line-height: 1.6; } /* Remove all animations and transitions for instant feedback */ *, *::before, *::after { animation: none !important; transition: background-color 0.1s ease !important; } /* Clean spacing */ .command-card { padding: 1.5rem !important; } /* Subtle, clean keyword badges */ .command-keyword { padding: 0.25rem 0.5rem; font-size: 0.875rem; } """ }, 'terminal': { 'name': 'Terminal/Hacker', 'description': 'Green/amber on black, monospace, retro terminal aesthetic', 'fonts_url': 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap', 'css_vars': """--bg-primary: #000000; --bg-secondary: #0a0a0a; --bg-card: #0f0f0f; --bg-card-hover: #1a1a1a; --accent-blue: #00ff00; --accent-cyan: #00ff00; --accent-orange: #ffb000; --accent-purple: #00ff00; --accent-red: #ff0000; --accent-yellow: #ffff00; --text-primary: #00ff00; --text-secondary: #00aa00; --text-muted: #008800; --border-subtle: rgba(0,255,0,0.3); --glow-blue: rgba(0,255,0,0.2); --glow-cyan: rgba(0,255,0,0.15);""", 'css_overrides': """ /* Terminal monospace font everywhere */ body, h1, h2, h3, h4, h5, h6, p, a, span, div, .command-name, .command-description, .command-usage, .command-keyword, .sidebar-nav a { font-family: 'JetBrains Mono', 'Courier New', monospace !important; } /* CRT scanline effect */ body::before { content: ''; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: repeating-linear-gradient( 0deg, rgba(0, 255, 0, 0.03), rgba(0, 255, 0, 0.03) 1px, transparent 1px, transparent 2px ); pointer-events: none; z-index: 9999; } /* Terminal text glow */ h1, h2, h3, .command-name { text-shadow: 0 0 10px rgba(0, 255, 0, 0.8); } .command-keyword, .command-usage { text-shadow: 0 0 5px rgba(0, 255, 0, 0.6); } /* Cursor blink effect after usage example */ .command-card:hover .command-usage::after { content: '▮'; display: inline-block; margin-left: 0.5rem; animation: blink 1s step-end infinite; color: var(--accent-blue); } @keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } /* Terminal prompt style for usage */ .command-usage::before { content: '$ '; color: var(--accent-yellow); font-weight: bold; } """ }, 'glass': { 'name': 'Glass/Glassmorphism', 'description': 'Frosted glass cards with blur effects and colorful gradients', 'fonts_url': 'https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap', 'css_vars': """--bg-primary: linear-gradient(135deg, #4c5fd7 0%, #764ba2 100%); --bg-secondary: rgba(255,255,255,0.05); --bg-card: rgba(255,255,255,0.1); --bg-card-hover: rgba(255,255,255,0.15); --accent-blue: #a8daff; --accent-cyan: #a8fff4; --accent-orange: #ffc085; --accent-purple: #d4b3ff; --accent-red: #ff9eb5; --accent-yellow: #ffe8a8; --text-primary: #ffffff; --text-secondary: rgba(255,255,255,0.8); --text-muted: rgba(255,255,255,0.6); --border-subtle: rgba(255,255,255,0.2); --glow-blue: rgba(168,218,255,0.2); --glow-cyan: rgba(168,255,244,0.15);""" }, 'neon': { 'name': 'Neon/Cyberpunk', 'description': 'Bright neon colors, dark backgrounds, futuristic aesthetic', 'fonts_url': 'https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700&display=swap', 'css_vars': """--bg-primary: #0a0014; --bg-secondary: #150028; --bg-card: #1a0033; --bg-card-hover: #25004d; --accent-blue: #00f5ff; --accent-cyan: #00f5ff; --accent-orange: #ff69b4; --accent-purple: #bf40bf; --accent-red: #ff1493; --accent-yellow: #ffd700; --text-primary: #ffffff; --text-secondary: #e9d5ff; --text-muted: #d8b4fe; --border-subtle: rgba(157,78,221,0.3); --glow-blue: rgba(0,245,255,0.4); --glow-cyan: rgba(247,37,133,0.3);""", 'css_overrides': """ /* Neon cyberpunk font */ body, h1, h2, h3, h4, h5, h6, .command-name, .sidebar-nav a { font-family: 'Orbitron', sans-serif !important; font-weight: 600; } /* Intense neon glow on headings */ h1 { text-shadow: 0 0 10px var(--accent-cyan), 0 0 20px var(--accent-cyan), 0 0 30px var(--accent-cyan), 0 0 40px var(--accent-blue); letter-spacing: 0.1em; text-transform: uppercase; } h2, h3 { text-shadow: 0 0 10px var(--accent-purple), 0 0 20px var(--accent-purple); letter-spacing: 0.05em; } .command-name { text-shadow: 0 0 10px var(--accent-cyan), 0 0 20px var(--accent-cyan); text-transform: uppercase; letter-spacing: 0.05em; } /* Neon border glow on cards - subtle */ .command-card { border: 2px solid var(--accent-cyan); box-shadow: 0 0 5px var(--glow-blue), inset 0 0 5px rgba(0,245,255,0.05); } .command-card:hover { border-color: var(--accent-orange); box-shadow: 0 0 10px var(--accent-cyan), 0 0 20px var(--accent-orange), inset 0 0 10px rgba(255,105,180,0.1); transform: translateY(-4px); } .intro-section { border: 2px solid var(--accent-purple); box-shadow: 0 0 8px var(--accent-purple), inset 0 0 8px rgba(191,64,191,0.05); } /* Neon keyword badges */ .command-keyword { text-shadow: 0 0 8px currentColor; border: 1px solid currentColor; box-shadow: 0 0 5px currentColor, inset 0 0 5px currentColor; } /* Glowing usage examples */ .command-usage { border-left: 3px solid var(--accent-cyan); box-shadow: -3px 0 10px var(--glow-blue); text-shadow: 0 0 5px rgba(0,245,255,0.5); } /* Animated neon flicker effect */ @keyframes neon-flicker { 0%, 100% { opacity: 1; } 41%, 43% { opacity: 0.8; } 45%, 47% { opacity: 0.9; } 49% { opacity: 0.85; } } h1, .command-name { animation: neon-flicker 3s infinite; } /* Cyberpunk grid overlay */ body::after { content: ''; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-image: linear-gradient(rgba(0,245,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(0,245,255,0.03) 1px, transparent 1px); background-size: 50px 50px; pointer-events: none; z-index: 1; } /* Ensure content is above grid */ .container { position: relative; z-index: 2; } /* Sidebar neon accent */ .sidebar-nav { border-right: 2px solid var(--accent-cyan); box-shadow: 2px 0 15px var(--glow-blue); } .sidebar-nav a:hover { text-shadow: 0 0 10px var(--accent-cyan); background: rgba(0,245,255,0.1); } """ }, 'brutalist': { 'name': 'Brutalist/Bold', 'description': 'High contrast, bold typography, thick borders, geometric', 'fonts_url': 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700;900&display=swap', 'css_vars': """--bg-primary: #ffffff; --bg-secondary: #f0f0f0; --bg-card: #ffffff; --bg-card-hover: #f5f5f5; --accent-blue: #0000cc; --accent-cyan: #006666; --accent-orange: #cc5200; --accent-purple: #5c00b8; --accent-red: #cc0000; --accent-yellow: #997700; --text-primary: #000000; --text-secondary: #1a1a1a; --text-muted: #4d4d4d; --border-subtle: rgba(0,0,0,1); --glow-blue: rgba(0,0,204,0.2); --glow-cyan: rgba(0,102,102,0.2);""", 'css_overrides': """ /* BRUTALIST: Square everything aggressively */ *, *::before, *::after { border-radius: 0 !important; } /* Space Grotesk everywhere, heavy weights */ body, h1, h2, h3, h4, h5, h6, p, a, span, div { font-family: 'Space Grotesk', sans-serif !important; } /* MASSIVE, BOLD headings */ h1 { font-weight: 900 !important; font-size: 4rem !important; letter-spacing: -0.05em; text-transform: uppercase; line-height: 0.9; } .category-title { font-weight: 900 !important; font-size: 2.5rem !important; text-transform: uppercase; letter-spacing: -0.03em; border-bottom: 8px solid #000000 !important; padding-bottom: 0.5rem !important; } .category-title .anchor-link { text-decoration: none; color: #000000; } .command-name { font-weight: 900 !important; font-size: 1.5rem !important; text-transform: uppercase; letter-spacing: -0.02em; } /* THICK borders everywhere */ .command-card { border: 6px solid #000000 !important; box-shadow: 12px 12px 0 #000000 !important; } .command-card:hover { box-shadow: 16px 16px 0 #000000 !important; transform: translate(-4px, -4px) !important; } /* Brutal intro box */ .header-content { border: 6px solid #000000 !important; box-shadow: 12px 12px 0 #000000 !important; border-radius: 0 !important; background: #ffffff !important; } .header-content::before { display: none !important; } header { border-radius: 0 !important; } /* Bold, chunky keyword badges */ .command-keyword { font-weight: 700 !important; border: 3px solid #000000 !important; padding: 0.5rem 1rem !important; background: #ffffff; text-transform: uppercase; font-size: 0.75rem !important; letter-spacing: 0.05em; } .command-keyword:nth-child(odd) { background: #000000; color: #ffffff; } /* Heavy usage box */ .command-usage { border: 4px solid #000000 !important; background: #f0f0f0 !important; padding: 1rem !important; font-weight: 700; } /* Aggressive sidebar - SQUARE */ .sidebar-nav { border-right: 8px solid #000000 !important; border-radius: 0 !important; } .sidebar-nav a { font-weight: 700 !important; text-transform: uppercase; font-size: 0.85rem !important; letter-spacing: 0.02em; border-radius: 0 !important; } .sidebar-nav a:hover { background: #000000 !important; color: #ffffff !important; } /* Remove blue hover bar on command cards */ .command-card::before { display: none !important; } /* CHANNEL CARDS - same brutal treatment */ .channel-card { border: 6px solid #000000 !important; box-shadow: 12px 12px 0 #000000 !important; border-radius: 0 !important; } .channel-card:hover { box-shadow: 16px 16px 0 #000000 !important; transform: translate(-4px, -4px) !important; } .channel-name { font-weight: 900 !important; font-size: 1.5rem !important; text-transform: uppercase; letter-spacing: -0.02em; } .channel-description { font-weight: 500 !important; } .channel-section h2 { font-weight: 900 !important; font-size: 2.5rem !important; text-transform: uppercase; letter-spacing: -0.03em; border-bottom: 8px solid #000000 !important; padding-bottom: 0.5rem !important; } /* Remove all smooth transitions - instant feedback */ * { transition: none !important; } /* Heavy typography throughout */ p, .command-description { font-weight: 500 !important; line-height: 1.5; } /* Brutal intro section */ .intro { font-weight: 700 !important; font-size: 1.2rem !important; } """ }, 'gradient': { 'name': 'Gradient/Modern', 'description': 'Colorful gradients, smooth transitions, vibrant colors', 'fonts_url': 'https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap', 'css_vars': """--bg-primary: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); --bg-secondary: rgba(0,0,0,0.08); --bg-card: rgba(255,255,255,0.95); --bg-card-hover: rgba(255,255,255,0.98); --accent-blue: #4338ca; --accent-cyan: #0891b2; --accent-orange: #ea580c; --accent-purple: #7c3aed; --accent-red: #dc2626; --accent-yellow: #ca8a04; --text-primary: #1a1a1a; --text-secondary: #374151; --text-muted: #6b7280; --border-subtle: rgba(0,0,0,0.15); --glow-blue: rgba(79,70,229,0.3); --glow-cyan: rgba(8,145,178,0.25);""", 'css_overrides': """ /* Vibrant gradient borders */ .command-card { border: 2px solid transparent; background: linear-gradient(white, white) padding-box, linear-gradient(135deg, #667eea, #764ba2, #f093fb) border-box; box-shadow: 0 4px 20px rgba(102, 126, 234, 0.15); background-origin: border-box; background-clip: padding-box, border-box; } .command-card:hover { box-shadow: 0 8px 30px rgba(102, 126, 234, 0.25), 0 0 0 1px rgba(102, 126, 234, 0.1); transform: translateY(-4px); } .intro-section { border: 2px solid transparent; background: linear-gradient(white, white) padding-box, linear-gradient(135deg, #667eea, #00d4ff) border-box; box-shadow: 0 4px 20px rgba(102, 126, 234, 0.15); } /* Gradient text for headings */ h1 { background: linear-gradient(135deg, #667eea, #764ba2, #f093fb); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } /* Keep section headers (category titles) visible with high contrast */ .category-title .anchor-link { background: linear-gradient(135deg, #1a1a1a, #374151); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-decoration: none; font-weight: 700; } .command-name { background: linear-gradient(135deg, #667eea, #764ba2); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; font-weight: 700; } /* Colorful keyword badges with gradients */ .command-keyword { background: linear-gradient(135deg, #667eea, #764ba2); color: white; border: none; font-weight: 600; } .command-keyword:nth-child(2) { background: linear-gradient(135deg, #f093fb, #667eea); } .command-keyword:nth-child(3) { background: linear-gradient(135deg, #764ba2, #f093fb); } .command-keyword:nth-child(4) { background: linear-gradient(135deg, #00d4ff, #667eea); } .command-keyword:nth-child(5) { background: linear-gradient(135deg, #f093fb, #00d4ff); } /* Animated gradient on hover */ @keyframes gradient-shift { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } } .command-card:hover .command-name { background: linear-gradient(135deg, #667eea, #764ba2, #f093fb, #00d4ff); background-size: 300% 300%; animation: gradient-shift 3s ease infinite; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } /* Gradient usage box */ .command-usage { background: linear-gradient(135deg, rgba(102, 126, 234, 0.05), rgba(240, 147, 251, 0.05)); border-left: 3px solid; border-image: linear-gradient(135deg, #667eea, #f093fb) 1; } /* Sidebar gradient accent */ .sidebar-nav { border-right: 3px solid transparent; border-image: linear-gradient(180deg, #667eea, #764ba2, #f093fb) 1; } .sidebar-nav a:hover { background: linear-gradient(90deg, rgba(102, 126, 234, 0.1), transparent); border-left: 3px solid #667eea; padding-left: 1.5rem; } """ }, 'pixel': { 'name': 'Pixel/Retro', 'description': 'Pixel art aesthetic, squared boxes, bright retro gaming colors', 'fonts_url': 'https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap', 'css_vars': """--bg-primary: #2b2d42; --bg-secondary: #3a3d5c; --bg-card: #4a4e69; --bg-card-hover: #5a5f7e; --accent-blue: #00b4d8; --accent-cyan: #00f5d4; --accent-orange: #ff6b35; --accent-purple: #b185db; --accent-red: #ef476f; --accent-yellow: #ffd60a; --text-primary: #ffffff; --text-secondary: #e0e0e0; --text-muted: #a8a8a8; --border-subtle: rgba(255,255,255,0.3); --glow-blue: rgba(0,180,216,0.4); --glow-cyan: rgba(0,245,212,0.3);""", 'css_overrides': """ /* Pixel art: remove ALL rounded corners */ *, *::before, *::after, .container, .intro-section, .sidebar-nav, .sidebar-nav a, .command-card, .command-keyword, .command-usage, .channel-section, .channel-card, .mobile-menu-toggle, button, input { border-radius: 0 !important; } /* Chunky pixel borders */ .command-card { border: 3px solid var(--border-subtle) !important; } .intro-section { border: 3px solid var(--border-subtle) !important; } .sidebar-nav { border: 3px solid var(--border-subtle) !important; } .command-keyword { border: 2px solid currentColor !important; } .command-usage { border: 2px solid var(--accent-cyan) !important; } .channel-section { border: 3px solid var(--border-subtle) !important; } /* Pixel font sizing adjustments */ body { font-family: 'Press Start 2P', cursive !important; line-height: 1.8; } h1 { font-size: 1.5rem !important; line-height: 1.6; } h2 { font-size: 1.2rem !important; line-height: 1.6; } h3 { font-size: 1rem !important; line-height: 1.6; } .command-name { font-size: 1.1rem !important; } p, .command-description, .command-usage { font-size: 0.75rem !important; line-height: 1.8; } .command-keyword { font-size: 0.65rem !important; padding: 4px 8px; } .sidebar-nav a { font-size: 0.7rem !important; } /* Retro box shadow - hard edges */ .command-card { box-shadow: 4px 4px 0 var(--border-subtle) !important; } .command-card:hover { box-shadow: 8px 8px 0 var(--accent-cyan) !important; transform: translate(-2px, -2px) !important; } .intro-section { box-shadow: 4px 4px 0 var(--border-subtle) !important; } /* Remove smooth transitions for pixel feel */ * { transition: none !important; } """ } } def setup_logging(): """Setup basic logging""" logging.basicConfig( level=logging.INFO, format='%(levelname)s: %(message)s' ) return logging.getLogger(__name__) def read_config(config_file: str = "config.ini") -> configparser.ConfigParser: """Read and parse config.ini file""" config = configparser.ConfigParser() if not os.path.exists(config_file): raise FileNotFoundError(f"Config file not found: {config_file}") config.read(config_file) return config def get_bot_name(config: configparser.ConfigParser) -> str: """Extract bot name from config""" return config.get('Bot', 'bot_name', fallback='MeshCore Bot') def get_admin_commands(config: configparser.ConfigParser) -> List[str]: """Extract admin commands from config""" if not config.has_section('Admin_ACL'): return [] admin_commands_str = config.get('Admin_ACL', 'admin_commands', fallback='') if not admin_commands_str: return [] return [cmd.strip() for cmd in admin_commands_str.split(',') if cmd.strip()] def get_website_intro(config: configparser.ConfigParser) -> str: """Get custom introduction text from config, or use default""" if config.has_section('Website'): 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." def get_website_title(config: configparser.ConfigParser) -> str: """Get website title from config, or use default""" if config.has_section('Website'): 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]]: """Load channels from Channels_List section, grouped by category Returns: Dict with structure: { 'general': {'#channel': 'description', ...}, 'category': {'#channel': 'description', ...}, ... } """ 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 else: # 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} def get_database_path(config: configparser.ConfigParser, bot_root: str) -> Optional[str]: """Get database path from config""" db_path = config.get('Bot', 'db_path', fallback='meshcore_bot.db') if db_path: return resolve_path(db_path, bot_root) return None 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 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 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(): if hasattr(cmd_instance, 'keywords') and command_name.lower() in [k.lower() for k in cmd_instance.keywords]: primary_name = cmd_instance.name if hasattr(cmd_instance, 'name') else cmd_name break 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]: """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]: """Get default command ordering when no stats available""" return [ 'help', 'ping', 'test', # Basic 'wx', 'aqi', # Weather 'sun', 'moon', 'solar', # Solar 'sports', 'stats', # Popular features ] 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] def escape_html(text: str) -> str: """Escape HTML special characters""" return html.escape(str(text)) 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 == []: # Empty list means DM only (channels explicitly disabled) return "DM only" elif len(allowed_channels) > 0: # Format channel names (add # if not present) formatted_channels = [] for ch in allowed_channels: ch = ch.strip() if not ch.startswith('#'): formatted_channels.append(f"#{ch}") else: formatted_channels.append(ch) if len(formatted_channels) == 1: 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: """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() if not ch.startswith('#'): 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: return f"{formatted[0]} or {formatted[1]}" else: 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: """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', 'weather': 'Weather Commands', 'solar': 'Solar & Astronomical', 'sports': 'Sports', 'games': 'Games & Entertainment', 'fun': 'Fun Commands', 'entertainment': 'Entertainment', 'meshcore_info': 'Mesh Network Info', 'analytics': 'Analytics', 'emergency': 'Emergency', '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']) 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'
{escape_html(description)}
\n' # Render usage, examples, parameters, subcommands try: # Render usage syntax if usage_syntax: commands_html += f'{escape_html(usage_syntax)}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".
Browse different visual styles for the command documentation.