diff --git a/modules/channel_manager.py b/modules/channel_manager.py index 5f1b3f0..26b13db 100644 --- a/modules/channel_manager.py +++ b/modules/channel_manager.py @@ -485,11 +485,11 @@ class ChannelManager: except TypeError: # If that doesn't work, try with hex string try: - res = await self.bot.meshcore.commands.set_channel(channel_idx, channel_name, channel_secret_hex or channel_secret.hex()) + res = await self.bot.meshcore.commands.set_channel(channel_idx, channel_name, channel_secret_hex or (channel_secret.hex() if channel_secret else "")) except TypeError: # Fallback to CLI method if API doesn't support key parameter self.logger.warning("meshcore.commands.set_channel doesn't accept key parameter, using CLI fallback") - return await self._add_channel_via_cli(channel_idx, channel_name, channel_secret.hex() if channel_secret else channel_secret_hex) + return await self._add_channel_via_cli(channel_idx, channel_name, channel_secret.hex() if channel_secret else (channel_secret_hex or "")) # Check for errors if hasattr(res, 'type') and res.type == EventType.ERROR: @@ -540,7 +540,7 @@ class ChannelManager: # For hashtag, generate key ourselves as fallback channel_secret = self.generate_hashtag_key(channel_name) channel_secret_hex = channel_secret.hex() - return await self._add_channel_via_cli(channel_idx, channel_name, channel_secret_hex) + return await self._add_channel_via_cli(channel_idx, channel_name, channel_secret_hex or "") except Exception as e: self.logger.error(f"Error adding channel {channel_idx}: {e}") diff --git a/modules/clients/espn_client.py b/modules/clients/espn_client.py index da6683f..d2814f1 100644 --- a/modules/clients/espn_client.py +++ b/modules/clients/espn_client.py @@ -222,8 +222,8 @@ class ESPNClient: # Get timestamp for sorting date_str = event.get('date', '') - timestamp = 0 - event_timestamp = None + timestamp: float = 0 + event_timestamp: Optional[float] = None if date_str: try: dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) diff --git a/modules/clients/thesportsdb_client.py b/modules/clients/thesportsdb_client.py index 9388fcd..717c17f 100644 --- a/modules/clients/thesportsdb_client.py +++ b/modules/clients/thesportsdb_client.py @@ -149,8 +149,8 @@ class TheSportsDBClient: away_abbr = get_team_abbreviation_from_name(away_team) # Get timestamp for sorting - timestamp = 0 - event_timestamp = None + timestamp: float = 0 + event_timestamp: Optional[float] = None if timestamp_str: try: # fromisoformat handles 'Z' in Python 3.11+, but we force UTC if naive @@ -266,7 +266,7 @@ class TheSportsDBClient: self.logger.error(f"TheSportsDB get_league_events_past error: {e}") return [] - async def get_events_by_day(self, date_str: str, league_id: str = None) -> list[dict]: + async def get_events_by_day(self, date_str: str, league_id: Optional[str] = None) -> list[dict]: """Get events for a specific day""" await self._rate_limit() url = f"{self.BASE_URL}/{self.FREE_API_KEY}/eventsday.php" diff --git a/modules/clients/wxsim_parser.py b/modules/clients/wxsim_parser.py index 653465c..393837b 100644 --- a/modules/clients/wxsim_parser.py +++ b/modules/clients/wxsim_parser.py @@ -31,7 +31,7 @@ import re from dataclasses import dataclass, field from datetime import datetime, timedelta from enum import Enum -from typing import Optional +from typing import Any, Optional import requests @@ -319,7 +319,7 @@ class WXSIMParser: Returns: List[ForecastPeriod]: Forecast periods """ - periods = [] + periods: list[Any] = [] if not hourly_data: return periods @@ -328,14 +328,14 @@ class WXSIMParser: day_separators = self._find_day_separators(lines) # Group hourly data by day - current_day = None - current_period_data = [] + current_day: Optional[str] = None + current_period_data: list[Any] = [] for data in hourly_data: # Check if this is a new day (by date string or hour reset) if current_day is None or data.date != current_day: # Save previous period if exists - if current_period_data: + if current_period_data and current_day is not None: period = self._create_period_from_hourly(current_day, current_period_data, day_separators) if period: periods.append(period) @@ -347,7 +347,7 @@ class WXSIMParser: current_period_data.append(data) # Add final period - if current_period_data: + if current_period_data and current_day is not None: period = self._create_period_from_hourly(current_day, current_period_data, day_separators) if period: periods.append(period) @@ -481,7 +481,7 @@ class WXSIMParser: return "Unknown" # Count condition occurrences - condition_counts = {} + condition_counts: dict[str, int] = {} for data in hourly_data: # Normalize condition text condition = data.weather.strip().upper() diff --git a/modules/command_manager.py b/modules/command_manager.py index c87a234..31f7cc3 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -330,6 +330,15 @@ class CommandManager: return True, "" + def _is_no_event_received(self, result) -> bool: + """Return True when result is an ERROR event with reason 'no_event_received'.""" + if not result or not hasattr(result, 'type'): + return False + if result.type != EventType.ERROR: + return False + payload = result.payload if hasattr(result, 'payload') else {} + return isinstance(payload, dict) and payload.get('reason') == 'no_event_received' + def _handle_send_result( self, result, @@ -552,6 +561,29 @@ class CommandManager: mesh_info=None # Keywords don't use mesh info placeholders ) + def get_max_message_length(self, message: MeshMessage) -> int: + """Return the effective max message length for *message* (DM=150, channel=150-prefix). + + Mirrors ``BaseCommand.get_max_message_length`` but works on the manager level so it + can be called outside of a specific command instance. + """ + if message.is_dm: + return 150 + username: str | None = None + try: + if hasattr(self.bot, 'meshcore') and self.bot.meshcore: + self_info = getattr(self.bot.meshcore, 'self_info', None) + if self_info: + if isinstance(self_info, dict): + username = self_info.get('name') or self_info.get('user_name') + else: + username = getattr(self_info, 'name', None) or getattr(self_info, 'user_name', None) + except Exception: + pass + if not username: + username = self.bot.config.get('Bot', 'bot_name', fallback='Bot') + return max(1, 150 - len(username) - 2) + def check_keywords(self, message: MeshMessage) -> list[tuple]: """Check message content for keywords and return matching responses. @@ -564,7 +596,7 @@ class CommandManager: Returns: List[tuple]: List of (trigger, response) tuples for matched keywords. """ - matches = [] + matches: list[tuple[str, Optional[str]]] = [] content = message.content.strip() # Check for command prefix if configured @@ -1022,15 +1054,29 @@ class CommandManager: if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"): await self.bot.meshcore.commands.set_flood_scope(scope_to_use) - try: - # Use meshcore_py directly (no meshcore-cli for channel sends) - result = await self.bot.meshcore.commands.send_chan_msg(channel_num, content) - finally: - if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"): - await self.bot.meshcore.commands.set_flood_scope("*") + target = f"{channel} (channel {channel_num})" + # Retry on no_event_received: max 2 extra attempts, 2s apart + _max_retries = 2 + for _attempt in range(_max_retries + 1): + try: + result = await self.bot.meshcore.commands.send_chan_msg(channel_num, content) + finally: + if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"): + await self.bot.meshcore.commands.set_flood_scope("*") + + if self._is_no_event_received(result) and _attempt < _max_retries: + self.logger.warning( + f"Channel message to {target}: no_event_received " + f"(attempt {_attempt + 1}/{_max_retries + 1}), retrying in 2s" + ) + await asyncio.sleep(2) + # Re-apply scope for next attempt + if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"): + await self.bot.meshcore.commands.set_flood_scope(scope_to_use) + continue + break # Handle result using unified handler - target = f"{channel} (channel {channel_num})" success = self._handle_send_result( result, "Channel message", target, rate_limit_key=rate_limit_key ) @@ -1110,7 +1156,7 @@ class CommandManager: return False return True - def get_help_for_command(self, command_name: str, message: MeshMessage = None) -> str: + def get_help_for_command(self, command_name: str, message: Optional[MeshMessage] = None) -> str: """Get help text for a specific command (LoRa-friendly compact format). Args: @@ -1191,7 +1237,7 @@ class CommandManager: _HELP_PREFIX = "Bot Help: " _HELP_SUFFIX = " | More: 'help '" - def get_general_help(self, message: MeshMessage = None) -> str: + def get_general_help(self, message: Optional[MeshMessage] = None) -> str: """Get general help text from config (LoRa-friendly compact format). When message is provided, only lists commands valid for the message's channel. @@ -1313,13 +1359,13 @@ class CommandManager: rate_limit_key = self.get_rate_limit_key(message) if message.is_dm: return await self.send_dm( - message.sender_id, content, + message.sender_id or "", content, skip_user_rate_limit=skip_user_rate_limit, rate_limit_key=rate_limit_key, ) else: return await self.send_channel_message( - message.channel, content, + message.channel or "", content, skip_user_rate_limit=skip_user_rate_limit, rate_limit_key=rate_limit_key, ) @@ -1327,6 +1373,37 @@ class CommandManager: self.logger.error(f"Failed to send response: {e}") return False + @staticmethod + def split_text_into_chunks(text: str, max_len: int) -> list[str]: + """Split *text* into a list of strings each at most *max_len* characters. + + Splitting prefers the last space within the limit so words are not broken; + if no space is found the chunk is hard-split at *max_len*. + + Args: + text: The text to split. + max_len: Maximum length of each chunk (must be >= 1). + + Returns: + List of non-empty chunk strings. Returns ``[""]`` when *text* is empty. + """ + if max_len < 1: + max_len = 1 + if len(text) <= max_len: + return [text] + chunks: list[str] = [] + while text: + if len(text) <= max_len: + chunks.append(text) + break + # Try to split on the last space within the window + split_at = text.rfind(' ', 0, max_len + 1) + if split_at <= 0: + split_at = max_len + chunks.append(text[:split_at].rstrip()) + text = text[split_at:].lstrip() + return chunks + async def send_response_chunked( self, message: MeshMessage, chunks: list[str], *, skip_user_rate_limit_first: bool = True ) -> bool: @@ -1357,7 +1434,7 @@ class CommandManager: await asyncio.sleep(sleep_time) skip = skip_user_rate_limit_first if i == 0 else True success = await self.send_dm( - message.sender_id, + message.sender_id or "", chunk, skip_user_rate_limit=skip, rate_limit_key=rate_limit_key, @@ -1370,7 +1447,7 @@ class CommandManager: return False return True return await self.send_channel_messages_chunked( - message.channel, + message.channel or "", chunks, skip_user_rate_limit=skip_user_rate_limit_first, rate_limit_key=rate_limit_key, @@ -1651,6 +1728,6 @@ class CommandManager: """Reload a specific plugin""" return self.plugin_loader.reload_plugin(plugin_name) - def get_plugin_metadata(self, plugin_name: str = None) -> dict[str, Any]: + def get_plugin_metadata(self, plugin_name: Optional[str] = None) -> dict[str, Any]: """Get plugin metadata""" return self.plugin_loader.get_plugin_metadata(plugin_name) diff --git a/modules/commands/advert_command.py b/modules/commands/advert_command.py index 246e9fe..f1243e2 100644 --- a/modules/commands/advert_command.py +++ b/modules/commands/advert_command.py @@ -44,7 +44,7 @@ class AdvertCommand(BaseCommand): """ return self.translate('commands.advert.description') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if advert command can be executed. Verifies both the standard command cooldowns and checks against the diff --git a/modules/commands/airplanes_command.py b/modules/commands/airplanes_command.py index 970b437..32c3451 100644 --- a/modules/commands/airplanes_command.py +++ b/modules/commands/airplanes_command.py @@ -53,7 +53,7 @@ class AirplanesCommand(BaseCommand): if self.api_url and not self.api_url.endswith('/'): self.api_url += '/' - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: @@ -572,7 +572,7 @@ class AirplanesCommand(BaseCommand): lines.append(line) # Build response, truncating if necessary - response_lines = [] + response_lines: list[str] = [] current_length = 0 for line in lines: diff --git a/modules/commands/alert_command.py b/modules/commands/alert_command.py index 5d3bc1c..a4a91bf 100644 --- a/modules/commands/alert_command.py +++ b/modules/commands/alert_command.py @@ -209,7 +209,7 @@ class AlertCommand(BaseCommand): if self.alert_enabled is None: self.alert_enabled = self.get_config_value('Alert_Command', 'alert_enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/announcements_command.py b/modules/commands/announcements_command.py index 5a38f53..7da783d 100644 --- a/modules/commands/announcements_command.py +++ b/modules/commands/announcements_command.py @@ -158,7 +158,7 @@ class AnnouncementsCommand(BaseCommand): return has_access - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if announcements command can be executed. Args: diff --git a/modules/commands/aqi_command.py b/modules/commands/aqi_command.py index fed1664..2d6fa72 100644 --- a/modules/commands/aqi_command.py +++ b/modules/commands/aqi_command.py @@ -113,7 +113,7 @@ class AqiCommand(BaseCommand): region = self.default_state or self.default_country return f"Usage: aqi - Get AQI for city/neighborhood in {region}, intl cities, coordinates, or help" - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/aurora_command.py b/modules/commands/aurora_command.py index 98734e2..6b9e430 100644 --- a/modules/commands/aurora_command.py +++ b/modules/commands/aurora_command.py @@ -38,7 +38,7 @@ class AuroraCommand(BaseCommand): self.default_country = self.bot.config.get("Weather", "default_country", fallback="US") self.url_timeout = 10 - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: if not self.aurora_enabled: return False return super().can_execute(message) diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index 6791f10..b01326a 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -37,10 +37,10 @@ class BaseCommand(ABC): examples: list[str] = [] # Example commands, e.g., ["wx 98101", "wx seattle tomorrow"] parameters: list[dict[str, str]] = [] # Parameter definitions, e.g., [{"name": "location", "description": "US zip code or city name"}] - def __init__(self, bot): + def __init__(self, bot: Any) -> None: self.bot = bot self.logger = bot.logger - self._last_execution_time = 0 + self._last_execution_time: float = 0.0 # Per-user cooldown tracking (for commands that need per-user rate limiting) self._user_cooldowns: dict[str, float] = {} @@ -54,7 +54,7 @@ class BaseCommand(ABC): # Cache command prefix from config self._command_prefix = self._load_command_prefix() - def translate(self, key: str, **kwargs) -> str: + def translate(self, key: str, **kwargs: Any) -> str: """Translate a key using the bot's translator. Args: @@ -585,7 +585,7 @@ class BaseCommand(ABC): # Global cooldown (backward compatibility) self._last_execution_time = current_time - def _record_execution(self, user_id: Optional[str] = None): + def _record_execution(self, user_id: Optional[str] = None) -> None: """Record the execution time for cooldown tracking (backward compatibility). Args: @@ -605,7 +605,7 @@ class BaseCommand(ABC): _, remaining = self.check_cooldown(user_id) return max(0, int(remaining)) - def _load_translated_keywords(self): + def _load_translated_keywords(self) -> None: """Load translated keywords from translation files""" if not hasattr(self.bot, 'translator'): self.logger.debug(f"Translator not available for {self.name}, skipping keyword loading") diff --git a/modules/commands/catfact_command.py b/modules/commands/catfact_command.py index 4042160..d092c63 100644 --- a/modules/commands/catfact_command.py +++ b/modules/commands/catfact_command.py @@ -125,7 +125,7 @@ class CatfactCommand(BaseCommand): # Return empty string so it doesn't appear in help return "" - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/channels_command.py b/modules/commands/channels_command.py index 3d561aa..67ebde3 100644 --- a/modules/commands/channels_command.py +++ b/modules/commands/channels_command.py @@ -44,7 +44,7 @@ class ChannelsCommand(BaseCommand): super().__init__(bot) self.channels_enabled = self.get_config_value('Channels_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/cmd_command.py b/modules/commands/cmd_command.py index 93c5f42..ca15d74 100644 --- a/modules/commands/cmd_command.py +++ b/modules/commands/cmd_command.py @@ -33,7 +33,7 @@ class CmdCommand(BaseCommand): super().__init__(bot) self.cmd_enabled = self.get_config_value('Cmd_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: @@ -135,7 +135,7 @@ class CmdCommand(BaseCommand): return ', '.join(command_names) # Build list within length limit - result = [] + result: list[str] = [] prefix = "Available commands: " current_length = len(prefix) diff --git a/modules/commands/dadjoke_command.py b/modules/commands/dadjoke_command.py index e8719af..6d13045 100644 --- a/modules/commands/dadjoke_command.py +++ b/modules/commands/dadjoke_command.py @@ -72,7 +72,7 @@ class DadJokeCommand(BaseCommand): content_lower = content.lower() return any(content_lower == keyword or content_lower.startswith(keyword + ' ') for keyword in self.keywords) - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Override to add custom check (dadjoke_enabled) while using base class cooldown. Args: diff --git a/modules/commands/dice_command.py b/modules/commands/dice_command.py index be3be6b..04953a4 100644 --- a/modules/commands/dice_command.py +++ b/modules/commands/dice_command.py @@ -53,7 +53,7 @@ class DiceCommand(BaseCommand): super().__init__(bot) self.dice_enabled = self.get_config_value('Dice_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/feed_command.py b/modules/commands/feed_command.py index 12efaa8..400bd44 100644 --- a/modules/commands/feed_command.py +++ b/modules/commands/feed_command.py @@ -29,7 +29,7 @@ class FeedCommand(BaseCommand): self.db_path = bot.db_manager.db_path self.feed_enabled = self.get_config_value('Feed_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed (enabled, admin only)""" if not self.feed_enabled: return False diff --git a/modules/commands/hello_command.py b/modules/commands/hello_command.py index 9b52e5b..9e2912e 100644 --- a/modules/commands/hello_command.py +++ b/modules/commands/hello_command.py @@ -360,7 +360,7 @@ class HelloCommand(BaseCommand): return bool(re.match(defined_emoji_pattern, cleaned_text)) - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/help_command.py b/modules/commands/help_command.py index 76501b9..d7170b0 100644 --- a/modules/commands/help_command.py +++ b/modules/commands/help_command.py @@ -42,7 +42,7 @@ class HelpCommand(BaseCommand): super().__init__(bot) self.help_enabled = self.get_config_value('Help_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/hfcond_command.py b/modules/commands/hfcond_command.py index 263dd71..c24912b 100644 --- a/modules/commands/hfcond_command.py +++ b/modules/commands/hfcond_command.py @@ -36,7 +36,7 @@ class HfcondCommand(BaseCommand): super().__init__(bot) self.hfcond_enabled = self.get_config_value('Hfcond_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/joke_command.py b/modules/commands/joke_command.py index 6ed19ab..2619b38 100644 --- a/modules/commands/joke_command.py +++ b/modules/commands/joke_command.py @@ -91,7 +91,7 @@ class JokeCommand(BaseCommand): content_lower = content.lower() return any(content_lower == keyword or content_lower.startswith(keyword + ' ') for keyword in self.keywords) - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Override to add custom checks (joke_enabled, dark joke) while using base class cooldown""" # Use base class for channel access, DM requirements, and cooldown if not super().can_execute(message): diff --git a/modules/commands/magic8_command.py b/modules/commands/magic8_command.py index b730ecd..f2af7e1 100644 --- a/modules/commands/magic8_command.py +++ b/modules/commands/magic8_command.py @@ -42,7 +42,7 @@ class Magic8Command(BaseCommand): super().__init__(bot) self.magic8_enabled = self.get_config_value('Magic8_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/moon_command.py b/modules/commands/moon_command.py index c4ed4d4..67ef3c8 100644 --- a/modules/commands/moon_command.py +++ b/modules/commands/moon_command.py @@ -31,7 +31,7 @@ class MoonCommand(BaseCommand): super().__init__(bot) self.moon_enabled = self.get_config_value('Moon_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/multitest_command.py b/modules/commands/multitest_command.py index e2d00d6..c3a0938 100644 --- a/modules/commands/multitest_command.py +++ b/modules/commands/multitest_command.py @@ -57,7 +57,7 @@ class MultitestCommand(BaseCommand): self._execution_lock = asyncio.Lock() return self._execution_lock - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/path_command.py b/modules/commands/path_command.py index d769399..7ec4e43 100644 --- a/modules/commands/path_command.py +++ b/modules/commands/path_command.py @@ -165,7 +165,7 @@ class PathCommand(BaseCommand): except Exception as e: self.logger.warning(f"Error reading bot location from config: {e} - geographic proximity guessing disabled") - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/ping_command.py b/modules/commands/ping_command.py index d77c552..b68bfb7 100644 --- a/modules/commands/ping_command.py +++ b/modules/commands/ping_command.py @@ -37,7 +37,7 @@ class PingCommand(BaseCommand): super().__init__(bot) self.ping_enabled = self.get_config_value('Ping_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/prefix_command.py b/modules/commands/prefix_command.py index 8f0edbf..706aefb 100644 --- a/modules/commands/prefix_command.py +++ b/modules/commands/prefix_command.py @@ -108,7 +108,7 @@ class PrefixCommand(BaseCommand): self.prefix_best_location_radius_km = 50.0 self.prefix_best_do_not_suggest = [] - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/reload_command.py b/modules/commands/reload_command.py index 02646b2..6f0e851 100644 --- a/modules/commands/reload_command.py +++ b/modules/commands/reload_command.py @@ -27,7 +27,7 @@ class ReloadCommand(BaseCommand): """ super().__init__(bot) - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed (admin only)""" if not self.requires_admin_access(): return False diff --git a/modules/commands/repeater_command.py b/modules/commands/repeater_command.py index 946b503..001997a 100644 --- a/modules/commands/repeater_command.py +++ b/modules/commands/repeater_command.py @@ -34,7 +34,7 @@ class RepeaterCommand(BaseCommand): super().__init__(bot) self.repeater_enabled = self.get_config_value('Repeater_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/roll_command.py b/modules/commands/roll_command.py index 0e59203..1aaaca9 100644 --- a/modules/commands/roll_command.py +++ b/modules/commands/roll_command.py @@ -41,7 +41,7 @@ class RollCommand(BaseCommand): super().__init__(bot) self.roll_enabled = self.get_config_value('Roll_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: @@ -161,7 +161,7 @@ class RollCommand(BaseCommand): # Default to 1-100 if no specification if content.lower() == "roll": - max_num = 100 + max_num: Optional[int] = 100 else: # Parse roll specification roll_part = content[5:].strip() # Get everything after "roll " diff --git a/modules/commands/satpass_command.py b/modules/commands/satpass_command.py index 0844f66..197913d 100644 --- a/modules/commands/satpass_command.py +++ b/modules/commands/satpass_command.py @@ -46,7 +46,7 @@ class SatpassCommand(BaseCommand): super().__init__(bot) self.satpass_enabled = self.get_config_value('Satpass_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/schedule_command.py b/modules/commands/schedule_command.py index 250867d..a9650d6 100644 --- a/modules/commands/schedule_command.py +++ b/modules/commands/schedule_command.py @@ -43,7 +43,7 @@ class ScheduleCommand(BaseCommand): # BaseCommand interface # ------------------------------------------------------------------ - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: if not self._enabled: return False if self._dm_only and not message.is_dm: diff --git a/modules/commands/solar_command.py b/modules/commands/solar_command.py index a9b64a2..7ffdf88 100644 --- a/modules/commands/solar_command.py +++ b/modules/commands/solar_command.py @@ -36,7 +36,7 @@ class SolarCommand(BaseCommand): super().__init__(bot) self.solar_enabled = self.get_config_value('Solar_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/solarforecast_command.py b/modules/commands/solarforecast_command.py index 8e44220..f8e5139 100644 --- a/modules/commands/solarforecast_command.py +++ b/modules/commands/solarforecast_command.py @@ -73,7 +73,7 @@ class SolarforecastCommand(BaseCommand): # Get database manager for geocoding cache self.db_manager = bot.db_manager - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/sports_command.py b/modules/commands/sports_command.py index 3419cf3..f2eaf1b 100644 --- a/modules/commands/sports_command.py +++ b/modules/commands/sports_command.py @@ -126,7 +126,7 @@ class SportsCommand(BaseCommand): return any(first_word == keyword.lower() for keyword in self.keywords) - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can execute with the given message""" if not self.sports_enabled: return False diff --git a/modules/commands/stats_command.py b/modules/commands/stats_command.py index bbbcd7e..3db176f 100644 --- a/modules/commands/stats_command.py +++ b/modules/commands/stats_command.py @@ -721,7 +721,7 @@ class StatsCommand(BaseCommand): if show_hashes: public_key, name, count, packet_hashes = row else: - public_key, name, count = row + public_key, name, count = row[0], row[1], row[2] packet_hashes = None # Truncate name if needed diff --git a/modules/commands/sun_command.py b/modules/commands/sun_command.py index 03b7537..6e5de58 100644 --- a/modules/commands/sun_command.py +++ b/modules/commands/sun_command.py @@ -35,7 +35,7 @@ class SunCommand(BaseCommand): super().__init__(bot) self.sun_enabled = self.get_config_value('Sun_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/test_command.py b/modules/commands/test_command.py index c8b2b65..41e72d0 100644 --- a/modules/commands/test_command.py +++ b/modules/commands/test_command.py @@ -63,7 +63,7 @@ class TestCommand(BaseCommand): except Exception as e: self.logger.warning(f"Error reading bot location from config: {e}") - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/trace_command.py b/modules/commands/trace_command.py index b4a0f37..e9dd0d1 100644 --- a/modules/commands/trace_command.py +++ b/modules/commands/trace_command.py @@ -47,7 +47,7 @@ class TraceCommand(BaseCommand): output_fmt = (self.bot.config.get("Trace_Command", "output_format", fallback="inline") or "inline").strip().lower() self.output_format = output_fmt if output_fmt in ("inline", "vertical") else "inline" - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: if not self.trace_enabled: return False return super().can_execute(message) diff --git a/modules/commands/webviewer_command.py b/modules/commands/webviewer_command.py index 8c4e9de..cb20492 100644 --- a/modules/commands/webviewer_command.py +++ b/modules/commands/webviewer_command.py @@ -28,7 +28,7 @@ class WebViewerCommand(BaseCommand): super().__init__(bot) self.webviewer_enabled = self.get_config_value('WebViewer_Command', 'enabled', fallback=True, value_type='bool') - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. Args: diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index 72f9f99..e273084 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -154,7 +154,7 @@ class WxCommand(BaseCommand): content_lower = content.lower() return any(content_lower.startswith(keyword + ' ') or content_lower == keyword for keyword in self.keywords) - def can_execute(self, message: MeshMessage) -> bool: + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Override to delegate or use base class cooldown""" # Check if wx command is enabled if not self.wx_enabled: @@ -170,7 +170,7 @@ class WxCommand(BaseCommand): # Use base class for cooldown and other checks return super().can_execute(message) - def get_remaining_cooldown(self, user_id: str) -> int: + def get_remaining_cooldown(self, user_id: Optional[str] = None) -> int: """Get remaining cooldown time for a specific user""" if self.delegate_command: return self.delegate_command.get_remaining_cooldown(user_id) diff --git a/modules/mesh_graph.py b/modules/mesh_graph.py index 7222c3d..c6f37c9 100644 --- a/modules/mesh_graph.py +++ b/modules/mesh_graph.py @@ -24,7 +24,7 @@ import threading import time from collections import defaultdict from datetime import datetime, timedelta -from typing import Optional +from typing import Any, Optional class MeshGraph: @@ -598,7 +598,7 @@ class MeshGraph: Prefers starred repeaters if there are somehow multiple entries (shouldn't happen with full key). """ cache_key = f"pk:{public_key}" if location_cache is not None else None - if cache_key is not None and cache_key in location_cache: + if cache_key is not None and location_cache is not None and cache_key in location_cache: return location_cache[cache_key] try: query = ''' @@ -621,7 +621,7 @@ class MeshGraph: lon = row.get('longitude') if lat is not None and lon is not None: result = (float(lat), float(lon)) - if cache_key is not None: + if cache_key is not None and location_cache is not None: location_cache[cache_key] = result return result except Exception as e: @@ -763,6 +763,8 @@ class MeshGraph: self.logger.debug(f"Mesh graph: Recalculated distance for {edge_key} using public keys: {recalculated_distance:.1f} km") try: + query: str + params: tuple[Any, ...] if is_new: # Upsert new edge. # Use INSERT ... ON CONFLICT DO UPDATE so that if the row already exists diff --git a/modules/message_handler.py b/modules/message_handler.py index b0033c3..623a358 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -1130,7 +1130,7 @@ class MessageHandler: - def decode_meshcore_packet(self, raw_hex: str, payload_hex: str = None) -> Optional[dict]: + def decode_meshcore_packet(self, raw_hex: str, payload_hex: Optional[str] = None) -> Optional[dict]: """ Decode a MeshCore packet from raw hex data - matches Packet.cpp exactly @@ -1360,7 +1360,7 @@ class MessageHandler: self.logger.error(f"Error parsing ADVERT payload: {e}", exc_info=True) return {} - def _path_bytes_to_nodes(self, path_bytes: bytes, prefix_hex_chars: int = None) -> tuple: + def _path_bytes_to_nodes(self, path_bytes: bytes, prefix_hex_chars: Optional[int] = None) -> tuple: """Chunk path bytes into hex node IDs using configured prefix length, with legacy 2-char fallback. Args: @@ -1419,8 +1419,8 @@ class MessageHandler: if not raw_hex: return (None, None, 255) if packet_info is None: - payload = payload_hex or rf_data.get('payload') - packet_info = self.decode_meshcore_packet(raw_hex, payload) + payload = payload_hex or rf_data.get('payload') or None + packet_info = self.decode_meshcore_packet(raw_hex, str(payload) if payload is not None else None) if not packet_info: return (None, None, 255) hops = packet_info.get('path_len', 255) @@ -2213,6 +2213,7 @@ class MessageHandler: # Use bot location as fallback reference to ensure distance-based selection bot_location_ref = self._get_bot_location_fallback() first_hop_temp_result = _get_node_location_from_db(self.bot, first_hop, bot_location_ref, recency_days) + first_hop_location_temp: Optional[tuple[float, float]] if first_hop_temp_result: first_hop_location_temp, _ = first_hop_temp_result else: @@ -2464,7 +2465,7 @@ class MessageHandler: except Exception as e: self.logger.error(f"Error discovering message path: {e}") - return 255 + return 255, "Error" # CLI path discovery removed - focusing only on packet decoding @@ -2703,17 +2704,22 @@ class MessageHandler: import time command_id = f"keyword_{keyword}_{message.sender_id}_{int(time.time())}" - # Send response (pass command_id so transmission record uses it directly) + # Send response — split into chunks so long responses are not truncated try: - rate_limit_key = self.bot.command_manager.get_rate_limit_key(message) - if message.is_dm: - success = await self.bot.command_manager.send_dm( - message.sender_id, response, command_id, rate_limit_key=rate_limit_key - ) + max_len = self.bot.command_manager.get_max_message_length(message) + chunks = self.bot.command_manager.split_text_into_chunks(response, max_len) + if len(chunks) == 1: + rate_limit_key = self.bot.command_manager.get_rate_limit_key(message) + if message.is_dm: + success = await self.bot.command_manager.send_dm( + message.sender_id, chunks[0], command_id, rate_limit_key=rate_limit_key + ) + else: + success = await self.bot.command_manager.send_channel_message( + message.channel, chunks[0], command_id, rate_limit_key=rate_limit_key + ) else: - success = await self.bot.command_manager.send_channel_message( - message.channel, response, command_id, rate_limit_key=rate_limit_key - ) + success = await self.bot.command_manager.send_response_chunked(message, chunks) if not success: self.logger.warning(f"Failed to send keyword response for '{keyword}' to {message.sender_id if message.is_dm else message.channel}") diff --git a/modules/plugin_loader.py b/modules/plugin_loader.py index f850b04..cef140a 100644 --- a/modules/plugin_loader.py +++ b/modules/plugin_loader.py @@ -19,11 +19,12 @@ from .commands.base_command import BaseCommand class PluginLoader: """Handles dynamic loading and discovery of command plugins""" - def __init__(self, bot, commands_dir: str = None, local_commands_dir: Optional[str] = None): + def __init__(self, bot: Any, commands_dir: Optional[str] = None, local_commands_dir: Optional[str] = None) -> None: self.bot = bot self.logger = bot.logger self.commands_dir = commands_dir or os.path.join(os.path.dirname(__file__), 'commands') self.alternatives_dir = os.path.join(self.commands_dir, 'alternatives') + self.local_commands_dir: Optional[str] if local_commands_dir is not None: self.local_commands_dir = local_commands_dir else: @@ -40,7 +41,7 @@ class PluginLoader: self._failed_plugins: dict[str, str] = {} # plugin_name -> error_message self._load_plugin_overrides() - def _load_plugin_overrides(self): + def _load_plugin_overrides(self) -> None: """Load plugin override configuration from config file""" self.plugin_overrides = {} try: @@ -58,7 +59,7 @@ class PluginLoader: def discover_plugins(self) -> list[str]: """Discover all Python files in the commands directory that could be plugins""" - plugin_files = [] + plugin_files: list[str] = [] commands_path = Path(self.commands_dir) if not commands_path.exists(): @@ -77,7 +78,7 @@ class PluginLoader: """Discover all Python files in the alternatives directory that could be plugins Note: Plugins in the 'inactive' subdirectory are ignored """ - plugin_files = [] + plugin_files: list[str] = [] alternatives_path = Path(self.alternatives_dir) if not alternatives_path.exists(): @@ -432,7 +433,7 @@ class PluginLoader: return loaded_plugins - def _build_keyword_mappings(self, plugin_name: str, metadata: dict[str, Any]): + def _build_keyword_mappings(self, plugin_name: str, metadata: dict[str, Any]) -> None: """Build keyword to plugin name mappings""" # Map keywords to plugin name for keyword in metadata.get('keywords', []): @@ -457,7 +458,7 @@ class PluginLoader: """Get all loaded plugins""" return self.loaded_plugins.copy() - def get_plugin_metadata(self, plugin_name: str = None) -> dict[str, Any]: + def get_plugin_metadata(self, plugin_name: Optional[str] = None) -> dict[str, Any]: """Get metadata for a specific plugin or all plugins""" if plugin_name: return self.plugin_metadata.get(plugin_name, {}) diff --git a/modules/profanity_filter.py b/modules/profanity_filter.py index 391070f..00c7bb0 100644 --- a/modules/profanity_filter.py +++ b/modules/profanity_filter.py @@ -23,16 +23,16 @@ _warned_unavailable = False _unidecode_available = False try: - from better_profanity import profanity # type: ignore + from better_profanity import profanity _profanity_available = True except ImportError: - profanity = None # type: ignore + profanity = None try: - from unidecode import unidecode # type: ignore + from unidecode import unidecode _unidecode_available = True except ImportError: - unidecode = None # type: ignore + unidecode = None def _has_hate_symbols(text: str) -> bool: diff --git a/modules/rate_limiter.py b/modules/rate_limiter.py index 321d82a..8a9b86b 100644 --- a/modules/rate_limiter.py +++ b/modules/rate_limiter.py @@ -182,7 +182,7 @@ class NominatimRateLimiter: def __init__(self, seconds: float = 1.1): self.seconds = seconds - self.last_request = 0 + self.last_request: float = 0.0 self._lock: Optional[asyncio.Lock] = None self._total_requests = 0 self._total_throttled = 0 diff --git a/modules/repeater_manager.py b/modules/repeater_manager.py index a0b2744..58ba1c0 100644 --- a/modules/repeater_manager.py +++ b/modules/repeater_manager.py @@ -8,7 +8,7 @@ import asyncio import json import time from datetime import datetime, timedelta -from typing import Optional +from typing import Any, Optional from meshcore import EventType @@ -308,7 +308,7 @@ class RepeaterManager: self.logger.info("Database schema migration completed") - async def track_contact_advertisement(self, advert_data: dict, signal_info: dict = None, packet_hash: Optional[str] = None) -> bool: + async def track_contact_advertisement(self, advert_data: dict, signal_info: Optional[dict] = None, packet_hash: Optional[str] = None) -> bool: """Track any contact advertisement in the complete tracking database""" try: # Extract basic information @@ -445,8 +445,8 @@ class RepeaterManager: return False async def _track_daily_advertisement(self, public_key: str, name: str, role: str, device_type: str, - location_info: dict, signal_strength: float, snr: float, - hop_count: int, timestamp: datetime, packet_hash: Optional[str] = None): + location_info: dict, signal_strength: Optional[float], snr: Optional[float], + hop_count: Optional[int], timestamp: datetime, packet_hash: Optional[str] = None): """Track daily advertisement statistics for accurate time-based reporting. Args: @@ -579,7 +579,7 @@ class RepeaterManager: # Default to companion for unknown contacts (human users) return 'companion' - def _determine_device_type(self, device_type: int, name: str, advert_data: dict = None) -> str: + def _determine_device_type(self, device_type: int, name: str, advert_data: Optional[dict] = None) -> str: """Determine device type string from numeric type and name following MeshCore specs""" from .enums import DeviceRole @@ -596,7 +596,7 @@ class RepeaterManager: return 'Sensor' else: # Handle any other mode values - return mode + return str(mode) # Fallback to legacy detection methods if device_type == 3: @@ -641,7 +641,7 @@ class RepeaterManager: except Exception as e: self.logger.error(f"Error updating currently tracked status: {e}") - async def get_complete_contact_database(self, role_filter: str = None, include_historical: bool = True) -> list[dict]: + async def get_complete_contact_database(self, role_filter: Optional[str] = None, include_historical: bool = True) -> list[dict]: """Get complete contact database for path estimation and analysis""" try: if include_historical: @@ -1161,9 +1161,9 @@ class RepeaterManager: self.logger.error(f"Error getting companions for purging: {e}") return [] - def _extract_location_data(self, contact_data: dict, should_geocode: bool = True, packet_hash: Optional[str] = None) -> dict[str, Optional[str]]: + def _extract_location_data(self, contact_data: dict, should_geocode: bool = True, packet_hash: Optional[str] = None) -> dict[str, Any]: """Extract location data from contact_data JSON""" - location_info = { + location_info: dict[str, Any] = { 'latitude': None, 'longitude': None, 'city': None, @@ -1305,7 +1305,7 @@ class RepeaterManager: return location_info - def _should_geocode_location(self, location_info: dict, existing_data: dict = None, name: str = "Unknown", packet_hash: Optional[str] = None) -> tuple[bool, dict]: + def _should_geocode_location(self, location_info: dict, existing_data: Optional[dict] = None, name: str = "Unknown", packet_hash: Optional[str] = None) -> tuple[bool, dict]: """ Determine if geocoding should be performed based on location changes. @@ -1549,7 +1549,7 @@ class RepeaterManager: def _get_full_location_from_coordinates(self, latitude: float, longitude: float, packet_hash: Optional[str] = None) -> dict[str, Optional[str]]: """Get complete location information (city, state, country) from coordinates using reverse geocoding""" - location_info = { + location_info: dict[str, Optional[str]] = { 'city': None, 'state': None, 'country': None @@ -1731,7 +1731,7 @@ class RepeaterManager: try: # Primary detection: Check device type field # Based on the actual contact data structure: - # type: 2 = repeater, type: 3 = room server + # device_type 2 = repeater, device_type 3 = room server device_type = contact_data.get('type') if device_type in [2, 3]: return True @@ -1815,7 +1815,7 @@ class RepeaterManager: self.logger.error(f"Error checking ACL membership: {e}") return False # Default to not in ACL on error (safer) - def _get_last_dm_activity(self, public_key: str, sender_id: str = None) -> Optional[datetime]: + def _get_last_dm_activity(self, public_key: str, sender_id: Optional[str] = None) -> Optional[datetime]: """Get the timestamp of the last DM from a contact""" try: @@ -2070,9 +2070,9 @@ class RepeaterManager: self.logger.error(f"Error retrieving repeater contacts: {e}") return [] - async def test_meshcore_cli_commands(self) -> dict[str, bool]: + async def test_meshcore_cli_commands(self) -> dict[str, Any]: """Test if meshcore-cli commands are working properly""" - results = {} + results: dict[str, Any] = {} try: from meshcore_cli.meshcore_cli import next_cmd @@ -2788,7 +2788,7 @@ class RepeaterManager: self.logger.error(f"Error in aggressive contact cleanup: {e}") return 0 - async def add_discovered_contact(self, contact_name: str, public_key: str = None, reason: str = "Manual addition") -> bool: + async def add_discovered_contact(self, contact_name: str, public_key: Optional[str] = None, reason: str = "Manual addition") -> bool: """Add a discovered contact to the contact list using multiple methods""" try: self.logger.info(f"Adding discovered contact: {contact_name}") @@ -3099,7 +3099,7 @@ class RepeaterManager: """Remove expired geocoding cache entries""" self.db_manager.cleanup_geocoding_cache() - async def populate_missing_geolocation_data(self, dry_run: bool = False, batch_size: int = 10) -> dict[str, int]: + async def populate_missing_geolocation_data(self, dry_run: bool = False, batch_size: int = 10) -> dict[str, Any]: """Populate missing geolocation data (state, country) for repeaters that have coordinates but missing location info""" try: # Check network connectivity first @@ -3426,7 +3426,7 @@ class RepeaterManager: for key, contact_data in self.bot.meshcore.contacts.items(): if self._is_repeater_device(contact_data): test_contact = contact_data - test_public_key = contact_data.get('public_key', key) + test_public_key = str(contact_data.get('public_key', key)) break if not test_contact: @@ -3441,7 +3441,8 @@ class RepeaterManager: self.logger.info(f"Testing purge system with contact: {contact_name}") - # Test the purge + # Test the purge (test_public_key is guaranteed non-None here; set in the loop above) + assert test_public_key is not None success = await self.purge_repeater_from_contacts(test_public_key, "Test purge - system validation") final_count = len(self.bot.meshcore.contacts) diff --git a/modules/scheduler.py b/modules/scheduler.py index 1ed2151..96dafa4 100644 --- a/modules/scheduler.py +++ b/modules/scheduler.py @@ -492,6 +492,7 @@ class MessageScheduler: # DB backup: evaluate schedule every 5 minutes if time.time() - self.last_db_backup_run >= 300: self._maybe_run_db_backup() + self.last_db_backup_run = time.time() time.sleep(1) @@ -905,7 +906,7 @@ class MessageScheduler: errors.append(f"set_custom_var(loop.detect, {value}) failed: {result}") success = len(errors) == 0 - response = {'results': results} + response: dict[str, Any] = {'results': results} if errors: response['errors'] = errors return success, response @@ -1231,13 +1232,28 @@ class MessageScheduler: bh, bm = 2, 0 scheduled_today = now.replace(hour=bh, minute=bm, second=0, microsecond=0) - if now < scheduled_today: - return # Backup time hasn't arrived yet today + + # Only fire within a 2-minute window after the scheduled time. + # This allows for scheduler lag while preventing a late bot startup + # from triggering an immediate backup for a time that passed hours ago. + fire_window_end = scheduled_today + datetime.timedelta(minutes=2) + if now < scheduled_today or now > fire_window_end: + return if sched == 'weekly' and now.weekday() != 0: # Monday only return - # Deduplicate: don't re-run if already ran today (daily) / this week (weekly) + # Deduplicate: don't re-run if already ran today (daily) / this week (weekly). + # Seed from DB on first check so restarts don't re-trigger a backup that + # already ran earlier today. + if not self._last_db_backup_stats: + try: + db_ran_at = self.bot.db_manager.get_metadata('maint.status.db_backup_ran_at') or '' + if db_ran_at: + self._last_db_backup_stats['ran_at'] = db_ran_at + except Exception: + pass + date_key = now.strftime('%Y-%m-%d') week_key = f"{now.year}-W{now.isocalendar()[1]}" last_ran = self._last_db_backup_stats.get('ran_at', '') diff --git a/modules/security_utils.py b/modules/security_utils.py index 513559e..cf7900f 100644 --- a/modules/security_utils.py +++ b/modules/security_utils.py @@ -77,7 +77,7 @@ def validate_external_url(url: str, allow_localhost: bool = False, timeout: floa old_timeout = socket.getdefaulttimeout() socket.setdefaulttimeout(timeout) try: - ip = socket.gethostbyname(parsed.hostname) + ip = socket.gethostbyname(parsed.hostname or "") finally: # Restore original timeout (None means no timeout, which is correct) socket.setdefaulttimeout(old_timeout) diff --git a/modules/service_plugin_loader.py b/modules/service_plugin_loader.py index 470371d..4800630 100644 --- a/modules/service_plugin_loader.py +++ b/modules/service_plugin_loader.py @@ -19,12 +19,13 @@ from .service_plugins.base_service import BaseServicePlugin class ServicePluginLoader: """Handles dynamic loading and discovery of service plugins""" - def __init__(self, bot, services_dir: str = None, local_services_dir: Optional[str] = None): + def __init__(self, bot, services_dir: Optional[str] = None, local_services_dir: Optional[str] = None): self.bot = bot self.logger = bot.logger - self.services_dir = services_dir or os.path.join( + self.services_dir: str = services_dir or os.path.join( os.path.dirname(__file__), 'service_plugins' ) + self.local_services_dir: Optional[str] if local_services_dir is not None: self.local_services_dir = local_services_dir else: @@ -54,7 +55,7 @@ class ServicePluginLoader: def discover_services(self) -> list[str]: """Discover all Python files in the service_plugins directory""" - service_files = [] + service_files: list[str] = [] services_path = Path(self.services_dir) if not services_path.exists(): @@ -296,7 +297,7 @@ class ServicePluginLoader: """Get all loaded services""" return self.loaded_services.copy() - def get_service_metadata(self, service_name: str = None) -> dict[str, Any]: + def get_service_metadata(self, service_name: Optional[str] = None) -> dict[str, Any]: """Get metadata for a specific service or all services""" if service_name: return self.service_metadata.get(service_name, {}) diff --git a/modules/service_plugins/base_service.py b/modules/service_plugins/base_service.py index b2072d3..0a4a104 100644 --- a/modules/service_plugins/base_service.py +++ b/modules/service_plugins/base_service.py @@ -22,6 +22,9 @@ class BaseServicePlugin(ABC): # Optional: Service description for metadata description: str = "" + # Optional: Service name for metadata + name: str = "" + def __init__(self, bot: Any): """Initialize the service plugin. diff --git a/modules/service_plugins/discord_bridge_service.py b/modules/service_plugins/discord_bridge_service.py index d66f3b2..b5ea556 100644 --- a/modules/service_plugins/discord_bridge_service.py +++ b/modules/service_plugins/discord_bridge_service.py @@ -10,6 +10,7 @@ import time from collections import deque from dataclasses import dataclass from datetime import datetime +from collections.abc import Mapping from typing import Any, Optional # Import meshcore @@ -20,7 +21,7 @@ try: import aiohttp AIOHTTP_AVAILABLE = True except ImportError: - aiohttp = None + aiohttp = None # type: ignore[assignment] AIOHTTP_AVAILABLE = False # Fallback to requests for sync HTTP @@ -28,7 +29,7 @@ try: import requests REQUESTS_AVAILABLE = True except ImportError: - requests = None + requests = None # type: ignore[assignment] REQUESTS_AVAILABLE = False # Import base service @@ -120,7 +121,7 @@ class DiscordBridgeService(BaseServicePlugin): # Message queue per webhook to handle rate limits and retries # Using list instead of deque for easier removal of arbitrary items - self.message_queues: dict[str, list] = {} + self.message_queues: dict[str, list[Any]] = {} self.max_retries = 5 # Maximum retry attempts per message self.retry_delay_base = 1.0 # Base delay in seconds for exponential backoff self.max_queue_age = 300 # Max age in seconds before dropping message (5 minutes) @@ -426,7 +427,7 @@ class DiscordBridgeService(BaseServicePlugin): except Exception as e: self.logger.error(f"Error handling mesh channel message: {e}", exc_info=True) - async def _queue_message(self, webhook_url: str, message: str, channel_name: str, sender_name: str = None) -> None: + async def _queue_message(self, webhook_url: str, message: str, channel_name: str, sender_name: Optional[str] = None) -> None: """Queue a message for posting to Discord webhook. Messages are queued and processed by a background task that handles @@ -609,6 +610,7 @@ class DiscordBridgeService(BaseServicePlugin): bool: True if message was successfully posted, False otherwise. """ try: + assert self.http_session is not None async with self.http_session.post(webhook_url, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as response: # Check response status if response.status == 204: @@ -700,7 +702,7 @@ class DiscordBridgeService(BaseServicePlugin): self.logger.error(f"Error posting to Discord webhook [{channel_name}]: {e}") return False - def _check_rate_limit_headers(self, headers: dict[str, str], webhook_url: str, channel_name: str) -> None: + def _check_rate_limit_headers(self, headers: Mapping[str, str], webhook_url: str, channel_name: str) -> None: """Check Discord rate limit headers and log warnings if approaching limit. Discord includes rate limit information in response headers: @@ -723,20 +725,20 @@ class DiscordBridgeService(BaseServicePlugin): reset = headers_dict.get('X-RateLimit-Reset') or headers_dict.get('x-ratelimit-reset') if limit and remaining: - limit = int(limit) - remaining = int(remaining) + limit_int = int(limit) + remaining_int = int(remaining) # Calculate percentage remaining - if limit > 0: - percent_remaining = remaining / limit + if limit_int > 0: + percent_remaining = remaining_int / limit_int # Store rate limit info if webhook_url not in self.rate_limit_info: self.rate_limit_info[webhook_url] = {} self.rate_limit_info[webhook_url].update({ - 'limit': limit, - 'remaining': remaining, + 'limit': limit_int, + 'remaining': remaining_int, 'reset': reset, 'last_check': time.time() }) @@ -746,14 +748,14 @@ class DiscordBridgeService(BaseServicePlugin): reset_time = datetime.fromtimestamp(float(reset)) if reset else 'unknown' self.logger.warning( f"Discord rate limit warning [{channel_name}]: " - f"{remaining}/{limit} requests remaining ({percent_remaining*100:.1f}%). " + f"{remaining_int}/{limit_int} requests remaining ({percent_remaining*100:.1f}%). " f"Resets at: {reset_time}" ) else: # Debug log current state self.logger.debug( f"Discord rate limit [{channel_name}]: " - f"{remaining}/{limit} requests remaining ({percent_remaining*100:.1f}%)" + f"{remaining_int}/{limit_int} requests remaining ({percent_remaining*100:.1f}%)" ) except (ValueError, TypeError, KeyError) as e: diff --git a/modules/service_plugins/map_uploader_service.py b/modules/service_plugins/map_uploader_service.py index 54bdf35..e4f02d0 100644 --- a/modules/service_plugins/map_uploader_service.py +++ b/modules/service_plugins/map_uploader_service.py @@ -24,7 +24,7 @@ try: import aiohttp AIOHTTP_AVAILABLE = True except ImportError: - aiohttp = None + aiohttp = None # type: ignore[assignment] AIOHTTP_AVAILABLE = False # Import cryptography for signature verification @@ -143,16 +143,16 @@ class MapUploaderService(BaseServicePlugin): # HTTP session # HTTP session - self.http_session: Optional[aiohttp.ClientSession] = None # type: ignore + self.http_session: Optional[aiohttp.ClientSession] = None # Event subscriptions - self.event_subscriptions = [] + self.event_subscriptions: list[Any] = [] # Exit flag self.should_exit = False # Cleanup tracking - self._last_cleanup_time = 0 + self._last_cleanup_time: float = 0 self._cleanup_interval = 3600 # Clean up every hour self.logger.info("Map uploader service initialized") @@ -214,7 +214,7 @@ class MapUploaderService(BaseServicePlugin): # Wait for bot to be connected max_wait = 30 # seconds - wait_time = 0 + wait_time: float = 0 while (not self.bot.connected or not self.meshcore) and wait_time < max_wait: await asyncio.sleep(0.5) wait_time += 0.5 @@ -238,7 +238,7 @@ class MapUploaderService(BaseServicePlugin): return # Create HTTP session - self.http_session = aiohttp.ClientSession() # type: ignore + self.http_session = aiohttp.ClientSession() # Setup event handlers await self._setup_event_handlers() @@ -839,6 +839,8 @@ class MapUploaderService(BaseServicePlugin): ValueError: If private key length is invalid. """ try: + if not self.private_key_hex or not self.public_key_hex: + raise ValueError("Private or public key not available") # Convert private key to bytes private_key_bytes = hex_to_bytes(self.private_key_hex) public_key_bytes = hex_to_bytes(self.public_key_hex) diff --git a/modules/service_plugins/packet_capture_service.py b/modules/service_plugins/packet_capture_service.py index 77d34dd..6906e2c 100644 --- a/modules/service_plugins/packet_capture_service.py +++ b/modules/service_plugins/packet_capture_service.py @@ -334,7 +334,7 @@ class PacketCaptureService(BaseServicePlugin): # Wait for bot to be connected (with timeout) max_wait = 30 # seconds - wait_time = 0 + wait_time: float = 0 while (not self.bot.connected or not self.meshcore) and wait_time < max_wait: await asyncio.sleep(0.5) wait_time += 0.5 diff --git a/modules/service_plugins/packet_capture_utils.py b/modules/service_plugins/packet_capture_utils.py index 7ab36e9..ef673bb 100644 --- a/modules/service_plugins/packet_capture_utils.py +++ b/modules/service_plugins/packet_capture_utils.py @@ -23,7 +23,7 @@ try: PYNACL_AVAILABLE = True except ImportError: PYNACL_AVAILABLE = False - nacl = None + nacl = None # type: ignore[assignment] # Fallback to cryptography library try: @@ -33,7 +33,7 @@ try: CRYPTOGRAPHY_AVAILABLE = True except ImportError: CRYPTOGRAPHY_AVAILABLE = False - ed25519 = None + ed25519 = None # type: ignore[assignment] def hex_to_bytes(hex_str: str) -> bytes: diff --git a/modules/service_plugins/telegram_bridge_service.py b/modules/service_plugins/telegram_bridge_service.py index 9d56e4b..0994112 100644 --- a/modules/service_plugins/telegram_bridge_service.py +++ b/modules/service_plugins/telegram_bridge_service.py @@ -20,14 +20,14 @@ try: import aiohttp AIOHTTP_AVAILABLE = True except ImportError: - aiohttp = None + aiohttp = None # type: ignore[assignment] AIOHTTP_AVAILABLE = False try: import requests REQUESTS_AVAILABLE = True except ImportError: - requests = None + requests = None # type: ignore[assignment] REQUESTS_AVAILABLE = False import contextlib @@ -409,6 +409,7 @@ class TelegramBridgeService(BaseServicePlugin): queued_msg: Optional[QueuedMessage] = None, ) -> bool: try: + assert self.http_session is not None async with self.http_session.post( url, json=payload, timeout=aiohttp.ClientTimeout(total=10) ) as response: diff --git a/modules/service_plugins/weather_service.py b/modules/service_plugins/weather_service.py index 6e105a8..5356f87 100644 --- a/modules/service_plugins/weather_service.py +++ b/modules/service_plugins/weather_service.py @@ -623,7 +623,10 @@ class WeatherService(BaseServicePlugin): alert_id_elem = entry.getElementsByTagName("id") if not alert_id_elem or not alert_id_elem[0].childNodes: continue - alert_id = alert_id_elem[0].childNodes[0].nodeValue + alert_id_value = alert_id_elem[0].childNodes[0].nodeValue + if not alert_id_value: + continue + alert_id: str = alert_id_value # Skip if we've already seen this alert if alert_id in self.seen_alert_ids: @@ -634,7 +637,8 @@ class WeatherService(BaseServicePlugin): updated_elem = entry.getElementsByTagName("updated") if updated_elem and updated_elem[0].childNodes: updated_str = updated_elem[0].childNodes[0].nodeValue - entry_updated_time = self._parse_iso_time(updated_str) + if updated_str is not None: + entry_updated_time = self._parse_iso_time(updated_str) # Extract full alert metadata (same logic as wx_command) alert_dict = self._parse_alert_entry(entry, alert_id) @@ -871,7 +875,7 @@ class WeatherService(BaseServicePlugin): return # Count strikes by bucket key - counter = {} + counter: dict[str, int] = {} for blitz in self.blitz_buffer: key = blitz['key'] counter[key] = counter.get(key, 0) + 1 @@ -1413,12 +1417,12 @@ class WeatherService(BaseServicePlugin): time_str = re.sub(r'(\d+):00(AM|PM)', r'\1\2', time_str) # Abbreviate month names - month_abbrevs = { + month_abbrev_map = { "January": "Jan", "February": "Feb", "March": "Mar", "April": "Apr", "May": "May", "June": "Jun", "July": "Jul", "August": "Aug", "September": "Sep", "October": "Oct", "November": "Nov", "December": "Dec" } - for full, abbrev in month_abbrevs.items(): + for full, abbrev in month_abbrev_map.items(): time_str = time_str.replace(full, abbrev) # Remove "at" before time diff --git a/modules/transmission_tracker.py b/modules/transmission_tracker.py index ea10bd4..ba9b0ac 100644 --- a/modules/transmission_tracker.py +++ b/modules/transmission_tracker.py @@ -7,7 +7,7 @@ Tracks transmitted message hashes and detects repeats from neighboring repeaters import time from contextlib import closing from dataclasses import dataclass, field -from typing import Optional +from typing import Any, Optional @dataclass @@ -196,7 +196,7 @@ class TransmissionTracker: db_path = resolve_path(self.bot.config.get('Web_Viewer', 'db_path').strip(), base_dir) else: from pathlib import Path - db_path = Path(self.bot.db_manager.db_path).resolve() + db_path = str(Path(self.bot.db_manager.db_path).resolve()) with closing(sqlite3.connect(str(db_path), timeout=30.0)) as conn: cursor = conn.cursor() @@ -249,7 +249,7 @@ class TransmissionTracker: self.logger.debug(f"Error updating command in database: {e}") def get_repeat_info(self, command_id: Optional[str] = None, - packet_hash: Optional[str] = None) -> dict[str, any]: + packet_hash: Optional[str] = None) -> dict[str, Any]: """Get repeat information for a command or packet hash. Args: diff --git a/modules/utils.py b/modules/utils.py index 6615f7d..57e6670 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -32,7 +32,7 @@ def is_valid_timezone(tz_str: str) -> bool: except ZoneInfoNotFoundError: return False try: - import pytz + import pytz # type: ignore[import-untyped] pytz.timezone(tz_str.strip()) return True except Exception: @@ -349,7 +349,7 @@ def decode_path_len_byte(path_len_byte: int, max_path_size: int = 64) -> tuple: return (path_byte_length, bytes_per_hop) -def calculate_packet_hash(raw_hex: str, payload_type: int = None) -> str: +def calculate_packet_hash(raw_hex: str, payload_type: Optional[int] = None) -> str: """Calculate hash for packet identification - based on packet.cpp. Packet hashes are unique to the originally sent message, allowing @@ -794,7 +794,7 @@ def rate_limited_nominatim_reverse_sync(bot: Any, coordinates: str, timeout: int return result -async def geocode_zipcode(bot: Any, zipcode: str, default_country: str = None, timeout: int = 10) -> tuple[Optional[float], Optional[float]]: +async def geocode_zipcode(bot: Any, zipcode: str, default_country: Optional[str] = None, timeout: int = 10) -> tuple[Optional[float], Optional[float]]: """Shared function to geocode a ZIP code to lat/lon coordinates. Checks cache first, then makes rate-limited API call if needed. @@ -832,7 +832,7 @@ async def geocode_zipcode(bot: Any, zipcode: str, default_country: str = None, t return None, None -def geocode_zipcode_sync(bot: Any, zipcode: str, default_country: str = None, timeout: int = 10) -> tuple[Optional[float], Optional[float]]: +def geocode_zipcode_sync(bot: Any, zipcode: str, default_country: Optional[str] = None, timeout: int = 10) -> tuple[Optional[float], Optional[float]]: """Synchronous version of geocode_zipcode. Args: @@ -868,8 +868,8 @@ def geocode_zipcode_sync(bot: Any, zipcode: str, default_country: str = None, ti return None, None -async def geocode_city(bot: Any, city: str, default_state: str = None, - default_country: str = None, +async def geocode_city(bot: Any, city: str, default_state: Optional[str] = None, + default_country: Optional[str] = None, include_address_info: bool = False, timeout: int = 10) -> tuple[Optional[float], Optional[float], Optional[dict]]: """Shared function to geocode a city name to lat/lon coordinates. @@ -1174,8 +1174,8 @@ async def geocode_city(bot: Any, city: str, default_state: str = None, return None, None, None -def geocode_city_sync(bot: Any, city: str, default_state: str = None, - default_country: str = None, +def geocode_city_sync(bot: Any, city: str, default_state: Optional[str] = None, + default_country: Optional[str] = None, include_address_info: bool = False, timeout: int = 10) -> tuple[Optional[float], Optional[float], Optional[dict]]: """Synchronous version of geocode_city. @@ -1687,7 +1687,7 @@ def calculate_path_distances(bot: Any, path_str: str) -> tuple[str, str]: # Look up locations for each node ID # _get_node_location_from_db returns ((lat, lon), public_key) or None - node_locations = [] + node_locations: list[Optional[tuple[float, float]]] = [] for node_id in node_ids: result = _get_node_location_from_db(bot, node_id) if result: @@ -1837,7 +1837,7 @@ def _get_node_location_from_db(bot: Any, node_id: str, reference_location: Optio # First sort by starred and distance, then stable sort by recency in reverse from datetime import datetime - def get_timestamp_key(ts_str): + def get_timestamp_key(ts_str: Optional[str]) -> float: """Convert timestamp string to sortable key (newer = smaller key for reverse sort)""" if not ts_str: return float('inf') # Empty timestamps sort last @@ -1875,7 +1875,7 @@ def _get_node_location_from_db(bot: Any, node_id: str, reference_location: Optio # For recency, parse timestamps properly to ensure newer comes first from datetime import datetime - def get_timestamp_key_no_ref(ts_str): + def get_timestamp_key_no_ref(ts_str: Optional[str]) -> float: """Convert timestamp string to sortable key (newer = smaller key)""" if not ts_str: return float('inf') # Empty timestamps sort last diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 0e6adf1..9ad90e1 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -16,7 +16,7 @@ import time from contextlib import closing, contextmanager, suppress from datetime import datetime from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, Union from flask import ( Flask, @@ -50,7 +50,7 @@ def _apply_werkzeug_websocket_fix() -> None: from engineio.async_drivers import _websocket_wsgi # noqa: PLC0415 _orig_call = _websocket_wsgi.SimpleWebSocketWSGI.__call__ - def _patched_call(self, environ, start_response): # type: ignore[misc] + def _patched_call(self, environ, start_response): # noqa: ANN001 result = _orig_call(self, environ, start_response) try: start_response('200 OK', [('Content-Length', '0')]) @@ -101,7 +101,7 @@ class BotDataViewer: 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_timeout=20, # 20 second ping timeout — 5s was too short when subscribe handlers replay DB history ping_interval=25, # 25 second ping interval (Flask-SocketIO 5.x default) logger=False, # Disable verbose logging engineio_logger=False, # Disable EngineIO logging @@ -124,6 +124,13 @@ class BotDataViewer: # Load configuration self.config = self._load_config(config_path) + # Resolve db_path relative to the config file's directory — matches core.py's bot_root + # property which is Path(config_file).parent.resolve(). Using self.bot_root (the project + # code root, 2 dirs above app.py) as the base caused a mismatch when config.ini lived + # elsewhere (e.g. a separate deployment directory), resulting in a blank realtime monitor + # because the web viewer and bot opened different database files. + self._config_base = Path(config_path).parent.resolve() if os.path.exists(config_path) else self.bot_root + # 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') @@ -131,7 +138,8 @@ class BotDataViewer: 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)) + self.db_path = str(resolve_path(use_db, self._config_base)) + self.logger.info(f"Using database: {self.db_path}") # Optional password authentication for web viewer (BUG-001) self.web_viewer_password = self.config.get('Web_Viewer', 'web_viewer_password', fallback='').strip() @@ -215,7 +223,7 @@ class BotDataViewer: def _get_version_info(self) -> dict[str, Optional[str]]: """Get version info for footer: tag if on a tag, else branch, commit hash and date. Checks MESHCORE_BOT_VERSION env (Docker/build), then .version_info, then git. Never raises.""" - out = {"tag": None, "branch": None, "commit": None, "date": None} + out: dict[str, Optional[str]] = {"tag": None, "branch": None, "commit": None, "date": None} # Docker / CI: version set at build time (e.g. ARG + ENV in Dockerfile) env_version = os.environ.get("MESHCORE_BOT_VERSION", "").strip() if env_version: @@ -293,17 +301,6 @@ class BotDataViewer: self.logger.exception("Template context processor failed: %s", e) return {'greeter_enabled': False, 'feed_manager_enabled': False, 'bot_name': 'MeshCore Bot', 'version_info': version_info} - 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: @@ -1443,6 +1440,13 @@ class BotDataViewer: def api_config_maintenance_post(): """Save DB backup and email hook settings to bot_metadata.""" data = request.get_json(silent=True) or {} + # Validate db_backup_dir before saving anything + if 'db_backup_dir' in data: + backup_dir = str(data['db_backup_dir']).strip() + if backup_dir and not os.path.isdir(backup_dir): + return jsonify({ + 'error': f"Backup directory does not exist: {backup_dir}", + }), 400 allowed = { 'db_backup_enabled', 'db_backup_schedule', 'db_backup_time', 'db_backup_retention_count', 'db_backup_dir', 'email_attach_log', @@ -1457,6 +1461,166 @@ class BotDataViewer: # ── Maintenance status ─────────────────────────────────────────────── + @self.app.route('/api/maintenance/backup_now', methods=['POST']) + def api_maintenance_backup_now(): + """Trigger an immediate DB backup outside the normal schedule.""" + try: + bot = getattr(self, 'bot', None) + scheduler = getattr(bot, 'scheduler', None) if bot else None + if scheduler is None or not hasattr(scheduler, '_run_db_backup'): + return jsonify({'success': False, 'error': 'Scheduler not available'}), 503 + scheduler._run_db_backup() + # Read outcome written by _run_db_backup + path = self.db_manager.get_metadata('maint.status.db_backup_path') or '' + outcome = self.db_manager.get_metadata('maint.status.db_backup_outcome') or '' + if outcome.startswith('error'): + return jsonify({'success': False, 'error': outcome}), 500 + return jsonify({'success': True, 'path': path, 'outcome': outcome}) + except Exception as e: + self.logger.error(f"Error in backup_now: {e}", exc_info=True) + return jsonify({'success': False, 'error': str(e)}), 500 + + @self.app.route('/api/maintenance/restore', methods=['POST']) + def api_maintenance_restore(): + """Restore DB from a backup file. + + Body: {"db_file": "/absolute/path/to/backup.db"} + The active DB is overwritten; the caller must restart the bot. + """ + try: + data = request.get_json(silent=True) or {} + db_file = str(data.get('db_file', '')).strip() + if not db_file: + return jsonify({'error': 'db_file is required'}), 400 + src = Path(db_file) + if not src.exists(): + return jsonify({'error': f'File not found: {db_file}'}), 400 + # Validate it is a real SQLite file by checking the magic header + _SQLITE_MAGIC = b"SQLite format 3\x00" + try: + with open(str(src), 'rb') as _fh: + _header = _fh.read(16) + if _header != _SQLITE_MAGIC: + raise ValueError("bad magic") + except Exception: + return jsonify({'error': f'Not a valid SQLite file: {db_file}'}), 400 + # Copy to active DB path + import shutil + shutil.copy2(str(src), self.db_path) + self.logger.info(f"Database restored from {src} to {self.db_path}") + return jsonify({ + 'success': True, + 'restored_from': db_file, + 'active_db': self.db_path, + 'warning': 'Restart the bot for the restored database to take effect.', + }) + except Exception as e: + self.logger.error(f"Error in restore: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + + @self.app.route('/api/maintenance/list_backups') + def api_maintenance_list_backups(): + """List available backup files from the configured backup directory.""" + try: + backup_dir_str = self.db_manager.get_metadata('maint.db_backup_dir') or '' + if not backup_dir_str or not os.path.isdir(backup_dir_str): + return jsonify({'backups': []}) + backup_dir = Path(backup_dir_str) + db_stem = Path(self.db_path).stem + files = sorted( + backup_dir.glob(f'{db_stem}_*.db'), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + backups = [ + { + 'path': str(f), + 'name': f.name, + 'size_mb': round(f.stat().st_size / 1_048_576, 2), + 'mtime': f.stat().st_mtime, + } + for f in files + ] + return jsonify({'backups': backups}) + except Exception as e: + self.logger.error(f"Error listing backups: {e}") + return jsonify({'error': str(e)}), 500 + + @self.app.route('/api/maintenance/purge', methods=['POST']) + def api_maintenance_purge(): + """Delete aged rows from time-series tables. + + Body: {"keep_days": |"all"} + Valid keep_days values: "all", 1, 7, 14, 30, 60, 90 + Returns: {"deleted": {: , ...}} + """ + _VALID_KEEP_DAYS = {"all", 1, 7, 14, 30, 60, 90} + try: + data = request.get_json(silent=True) or {} + raw = data.get('keep_days', 'all') + if raw == 'all' or raw == 'All': + keep_days: Union[str, int] = 'all' + else: + try: + keep_days = int(raw) + except (TypeError, ValueError): + return jsonify({'error': f'Invalid keep_days: {raw!r}'}), 400 + if keep_days not in _VALID_KEEP_DAYS: + return jsonify({'error': f'keep_days must be one of {sorted(v for v in _VALID_KEEP_DAYS if isinstance(v, int))} or "all"'}), 400 + + deleted: dict[str, int] = {} + if keep_days == 'all': + # Nothing to delete — keep everything + return jsonify({'deleted': deleted}) + + assert isinstance(keep_days, int) + from datetime import timedelta as _timedelta + cutoff_unix = time.time() - keep_days * 86400 + _cutoff_dt = datetime.utcnow() - _timedelta(days=keep_days) + cutoff_iso = _cutoff_dt.strftime('%Y-%m-%d %H:%M:%S') + cutoff_date = _cutoff_dt.strftime('%Y-%m-%d') + + # (table, sql, params) — tables created lazily by other modules may not exist + _purge_ops = [ + ('packet_stream', + 'DELETE FROM packet_stream WHERE timestamp < ?', + (cutoff_unix,)), + ('message_stats', + 'DELETE FROM message_stats WHERE timestamp < ?', + (int(cutoff_unix),)), + ('complete_contact_tracking', + 'DELETE FROM complete_contact_tracking WHERE last_heard < ?', + (cutoff_iso,)), + ('purging_log', + 'DELETE FROM purging_log WHERE timestamp < ?', + (cutoff_iso,)), + ('mesh_connections', + 'DELETE FROM mesh_connections WHERE last_seen < ?', + (cutoff_iso,)), + ('daily_stats', + 'DELETE FROM daily_stats WHERE date < ?', + (cutoff_date,)), + ] + with self.db_manager.connection() as conn: + cur = conn.cursor() + for tbl, sql, params in _purge_ops: + try: + cur.execute(sql, params) + deleted[tbl] = cur.rowcount + except Exception: + deleted[tbl] = 0 + conn.commit() + + total = sum(deleted.values()) + self.logger.info( + f"Purge completed: keep_days={keep_days}, total_deleted={total}, by_table={deleted}" + ) + return jsonify({'deleted': deleted}) + + except Exception as e: + self.logger.error(f"Error in purge: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 + @self.app.route('/api/maintenance/status') def api_maintenance_status(): """Return last-run times and outcomes for all maintenance jobs.""" @@ -1635,6 +1799,24 @@ class BotDataViewer: self.logger.error(f"Error getting rate limiter stats: {e}") return jsonify({'error': str(e)}), 500 + @self.app.route('/api/connected_clients') + def api_connected_clients(): + """Return list of currently connected web viewer clients.""" + try: + with self._clients_lock: + clients = [ + { + 'client_id': cid[:8] + '…' if len(cid) > 8 else cid, + 'connected_at': info.get('connected_at'), + 'last_activity': info.get('last_activity'), + } + for cid, info in self.connected_clients.items() + ] + return jsonify(clients) + except Exception as e: + self.logger.error(f"Error getting connected clients: {e}") + return jsonify({'error': str(e)}), 500 + @self.app.route('/api/contacts') def api_contacts(): """Get contact data. Optional query param: since=24h|7d|30d|90d|all (default 30d).""" @@ -3177,6 +3359,7 @@ class BotDataViewer: 'last_activity': time.time(), 'subscribed_commands': False, 'subscribed_packets': False, + 'subscribed_messages': False, 'subscribed_mesh': False, 'subscribed_logs': False, } @@ -3234,7 +3417,7 @@ class BotDataViewer: except Exception: pass except Exception as e: - self.logger.debug(f"Error replaying command history: {e}") + self.logger.warning(f"Error replaying command history: {e}", exc_info=True) except Exception as e: self.logger.error(f"Error in handle_subscribe_commands: {e}", exc_info=True) @@ -3267,7 +3450,7 @@ class BotDataViewer: except Exception: pass except Exception as e: - self.logger.debug(f"Error replaying packet history: {e}") + self.logger.warning(f"Error replaying packet history: {e}", exc_info=True) except Exception as e: self.logger.error(f"Error in handle_subscribe_packets: {e}", exc_info=True) @@ -3311,7 +3494,7 @@ class BotDataViewer: except Exception: pass except Exception as e: - self.logger.debug(f"Error replaying message history: {e}") + self.logger.warning(f"Error replaying message history: {e}", exc_info=True) except Exception as e: self.logger.error(f"Error in handle_subscribe_messages: {e}", exc_info=True) @@ -3330,7 +3513,7 @@ class BotDataViewer: try: log_file = self.config.get('Logging', 'log_file', fallback='').strip() if log_file: - log_file = str(resolve_path(log_file, self.bot_root)) + log_file = str(resolve_path(log_file, self._config_base)) except Exception: pass if log_file and os.path.exists(log_file): @@ -3466,7 +3649,7 @@ class BotDataViewer: try: log_file = self.config.get('Logging', 'log_file', fallback='').strip() if log_file: - log_file = str(resolve_path(log_file, self.bot_root)) + log_file = str(resolve_path(log_file, self._config_base)) except Exception: pass @@ -5128,7 +5311,7 @@ class BotDataViewer: if conn: conn.close() - def _preview_feed_items(self, feed_url: str, feed_type: str, output_format: str, api_config: dict = None, filter_config: dict = None, sort_config: dict = None) -> list[dict[str, Any]]: + def _preview_feed_items(self, feed_url: str, feed_type: str, output_format: str, api_config: Optional[dict[str, Any]] = None, filter_config: Optional[dict[str, Any]] = None, sort_config: Optional[dict[str, Any]] = None) -> list[dict[str, Any]]: """Preview feed items with custom output format (standalone, doesn't require bot)""" from datetime import datetime, timezone @@ -5150,7 +5333,8 @@ class BotDataViewer: published = None if hasattr(entry, 'published_parsed') and entry.published_parsed: with suppress(Exception): - published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc) + pt = entry.published_parsed + published = datetime(pt[0], pt[1], pt[2], pt[3], pt[4], pt[5], tzinfo=timezone.utc) items.append({ 'title': entry.get('title', 'Untitled'), @@ -5161,6 +5345,8 @@ class BotDataViewer: elif feed_type == 'api': # Fetch API feed + if api_config is None: + raise ValueError("api_config is required for API feed type") method = api_config.get('method', 'GET').upper() headers = api_config.get('headers', {}) params = api_config.get('params', {}) @@ -5191,6 +5377,7 @@ class BotDataViewer: # Extract items using parser config items_path = parser_config.get('items_path', '') + items_data: dict[Any, Any] | list[Any] if items_path: parts = items_path.split('.') items_data = data @@ -5205,7 +5392,8 @@ class BotDataViewer: items_data = data elif isinstance(data, dict): # If it's a dict, try to find common array fields - items_data = data.get('items', data.get('data', data.get('results', [data]))) + _found = data.get('items') or data.get('data') or data.get('results') + items_data = _found if _found is not None else [data] else: items_data = [data] diff --git a/modules/web_viewer/integration.py b/modules/web_viewer/integration.py index 89701ec..a831580 100644 --- a/modules/web_viewer/integration.py +++ b/modules/web_viewer/integration.py @@ -5,6 +5,7 @@ Provides integration between the main bot and the web viewer """ import os +import queue import re import subprocess import sys @@ -24,16 +25,25 @@ class BotIntegration: CIRCUIT_BREAKER_THRESHOLD = 3 CIRCUIT_BREAKER_COOLDOWN_SEC = 60 + # How often (seconds) the drain thread flushes the write queue + DRAIN_INTERVAL = 0.5 + def __init__(self, bot): self.bot = bot self.circuit_breaker_open = False self.circuit_breaker_failures = 0 self.circuit_breaker_last_failure_time = 0.0 self.is_shutting_down = False + # Batched write queue: avoids a per-insert sqlite3.connect() round-trip + self._write_queue: queue.Queue = queue.Queue() + self._drain_stop = threading.Event() + self._drain_thread: Optional[threading.Thread] = None # Initialize HTTP session with connection pooling for efficient reuse self._init_http_session() # Initialize the packet_stream table self._init_packet_stream_table() + # Start background drain thread after table is confirmed to exist + self._start_drain_thread() def _init_http_session(self): """Initialize a requests.Session with connection pooling and keep-alive""" @@ -169,32 +179,62 @@ class BotIntegration: # Don't raise - allow bot to continue even if table init fails # The error will be caught when trying to insert data - def _insert_packet_stream_row(self, data_json: str, row_type: str, log_prefix: str = "packet data"): - """Insert one row into packet_stream. Retries on database is locked. Logs and returns on failure.""" + def _start_drain_thread(self) -> None: + """Start the background thread that flushes the write queue every DRAIN_INTERVAL seconds.""" + self._drain_thread = threading.Thread( + target=self._drain_loop, + name="packet-stream-drain", + daemon=True, + ) + self._drain_thread.start() + + def _drain_loop(self) -> None: + """Background loop: flush the write queue every DRAIN_INTERVAL seconds.""" + while not self._drain_stop.is_set(): + self._drain_stop.wait(timeout=self.DRAIN_INTERVAL) + self._flush_write_queue() + + def _flush_write_queue(self) -> None: + """Drain all queued rows and insert them in a single batched transaction.""" import sqlite3 - import time + if self._write_queue.empty(): + return + rows = [] + while not self._write_queue.empty(): + try: + rows.append(self._write_queue.get_nowait()) + except queue.Empty: + break + if not rows: + return db_path = self._get_web_viewer_db_path() max_retries = 3 for attempt in range(max_retries): try: with closing(sqlite3.connect(str(db_path), timeout=60.0)) as conn: - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO packet_stream (timestamp, data, type) - VALUES (?, ?, ?) - ''', (time.time(), data_json, row_type)) + conn.executemany( + 'INSERT INTO packet_stream (timestamp, data, type) VALUES (?, ?, ?)', + rows, + ) conn.commit() - return + return except sqlite3.OperationalError as e: if "locked" in str(e).lower() and attempt < max_retries - 1: time.sleep(0.15 * (attempt + 1)) continue - self.bot.logger.warning(f"Error storing {log_prefix} for web viewer: {e}") + self.bot.logger.warning(f"Error flushing packet_stream queue ({len(rows)} rows): {e}") return except Exception as e: - self.bot.logger.warning(f"Error storing {log_prefix} for web viewer: {e}") + self.bot.logger.warning(f"Error flushing packet_stream queue ({len(rows)} rows): {e}") return + def _insert_packet_stream_row(self, data_json: str, row_type: str, log_prefix: str = "packet data"): + """Queue one row for batched insertion into packet_stream by the drain thread.""" + try: + self._write_queue.put_nowait((time.time(), data_json, row_type)) + except Exception as e: + self.bot.logger.warning(f"Error queuing {log_prefix} for web viewer: {e}") + def capture_full_packet_data(self, packet_data): """Capture full packet data and store in database for web viewer""" try: @@ -423,8 +463,13 @@ class BotIntegration: self.bot.logger.debug(f"Error sending mesh node update to web viewer: {e}") def shutdown(self): - """Mark as shutting down and close HTTP session""" + """Mark as shutting down, stop drain thread, flush remaining rows, and close HTTP session.""" self.is_shutting_down = True + # Stop drain thread and do a final flush of any queued rows + self._drain_stop.set() + if self._drain_thread and self._drain_thread.is_alive(): + self._drain_thread.join(timeout=5.0) + self._flush_write_queue() # Close HTTP session to clean up connections if hasattr(self, 'http_session') and self.http_session: with suppress(Exception): diff --git a/modules/web_viewer/templates/base.html b/modules/web_viewer/templates/base.html index bbadedf..b691a3a 100644 --- a/modules/web_viewer/templates/base.html +++ b/modules/web_viewer/templates/base.html @@ -480,10 +480,12 @@ } initializeSocket() { + // Use io() without forceNew so that realtime.html's io() shares + // this same manager/socket. forceNew created a second independent + // connection that interfered with the realtime-page socket. this.socket = io({ transports: ['websocket', 'polling'], - timeout: 5000, - forceNew: true + timeout: 5000 }); this.setupSocketEvents(); diff --git a/modules/web_viewer/templates/config.html b/modules/web_viewer/templates/config.html index 6c5c05d..f751c3a 100644 --- a/modules/web_viewer/templates/config.html +++ b/modules/web_viewer/templates/config.html @@ -204,7 +204,8 @@ -
Absolute path. The directory will be created if it does not exist.
+
Absolute path. The directory must already exist.
+
@@ -222,6 +223,14 @@ + +
@@ -255,6 +264,131 @@ + + +
+
+
+
+
+
Database Operations
+
+
+

+ Permanently delete old records from time-series tables + (packet_stream, message_stats, + complete_contact_tracking, purging_log, + mesh_connections, daily_stats). + This cannot be undone — back up first if needed. +

+
+
+ + +
+
+ +
+
+ +
+
+
+ + +
TableDeleted
+ + + + + + + + + + + + + {% endblock %} {% block extra_js %} @@ -476,6 +610,7 @@ class DbBackupManager { this.timeEl = document.getElementById('db-backup-time'); this.retentionEl = document.getElementById('db-backup-retention-count'); this.dirEl = document.getElementById('db-backup-dir'); + this.dirErrorEl = document.getElementById('db-backup-dir-error'); this.attachLogEl = document.getElementById('email-attach-log'); this.saveBtn = document.getElementById('save-db-backup-btn'); this.statusEl = document.getElementById('db-backup-status'); @@ -497,12 +632,39 @@ class DbBackupManager { this.show('Failed to load: ' + err.message, 'danger'); } this.saveBtn.addEventListener('click', () => this.save()); + const backupNowBtn = document.getElementById('backup-now-btn'); + if (backupNowBtn) backupNowBtn.addEventListener('click', () => this.backupNow()); + } + + async backupNow() { + const btn = document.getElementById('backup-now-btn'); + if (btn) { + btn.disabled = true; + btn.innerHTML = 'Running…'; + } + this.hide(); + try { + const resp = await fetch('/api/maintenance/backup_now', { method: 'POST' }); + const data = await resp.json(); + if (!resp.ok || !data.success) throw new Error(data.error || 'Backup failed'); + const pathInfo = data.path ? ` → ${data.path}` : ''; + this.show(`Backup complete${pathInfo}`, 'success'); + } catch (err) { + this.show('Backup failed: ' + err.message, 'danger'); + } finally { + if (btn) { + btn.disabled = false; + btn.innerHTML = 'Backup Now'; + } + } } async save() { this.saveBtn.disabled = true; this.saveBtn.innerHTML = 'Saving…'; this.hide(); + if (this.dirErrorEl) this.dirErrorEl.style.display = 'none'; + if (this.dirEl) this.dirEl.classList.remove('is-invalid'); try { const resp = await fetch('/api/config/maintenance', { method: 'POST', @@ -517,7 +679,16 @@ class DbBackupManager { }), }); const data = await resp.json(); - if (!resp.ok) throw new Error(data.error || 'Save failed'); + if (!resp.ok) { + if (data.error && data.error.includes('Backup directory')) { + if (this.dirEl) this.dirEl.classList.add('is-invalid'); + if (this.dirErrorEl) { + this.dirErrorEl.textContent = data.error; + this.dirErrorEl.style.display = ''; + } + } + throw new Error(data.error || 'Save failed'); + } this.show('Settings saved.', 'success'); } catch (err) { this.show('Save failed: ' + err.message, 'danger'); @@ -576,10 +747,153 @@ class MaintenanceStatusManager { } } +class RestoreManager { + constructor() { + this.pathEl = document.getElementById('restore-db-path'); + this.confirmBtn = document.getElementById('restore-confirm-btn'); + this.statusEl = document.getElementById('restore-status'); + if (this.confirmBtn) this.confirmBtn.addEventListener('click', () => this.restore()); + window.restoreManager = this; + } + + async load() { + const loading = document.getElementById('restore-backup-loading'); + const empty = document.getElementById('restore-backup-empty'); + const table = document.getElementById('restore-backup-table'); + const tbody = document.getElementById('restore-backup-tbody'); + if (!tbody) return; + loading.style.display = ''; + empty.style.display = 'none'; + table.style.display = 'none'; + tbody.innerHTML = ''; + try { + const resp = await fetch('/api/maintenance/list_backups'); + const data = await resp.json(); + loading.style.display = 'none'; + if (!data.backups || data.backups.length === 0) { + empty.style.display = ''; + return; + } + table.style.display = ''; + for (const b of data.backups) { + const dt = new Date(b.mtime * 1000).toLocaleString(); + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${b.name} + ${b.size_mb} MB + ${dt} + `; + tbody.appendChild(tr); + } + } catch (e) { + loading.style.display = 'none'; + empty.style.display = ''; + empty.textContent = 'Failed to load backup list.'; + } + } + + async restore() { + const path = this.pathEl ? this.pathEl.value.trim() : ''; + if (!path) { this.showStatus('Please enter a backup file path.', 'danger'); return; } + if (!confirm(`Restore database from:\n${path}\n\nThis will overwrite the active database. Continue?`)) return; + this.confirmBtn.disabled = true; + this.confirmBtn.innerHTML = 'Restoring…'; + try { + const resp = await fetch('/api/maintenance/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ db_file: path }), + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Restore failed'); + this.showStatus(`Restored successfully. ${data.warning || ''}`, 'success'); + } catch (err) { + this.showStatus('Restore failed: ' + err.message, 'danger'); + } finally { + this.confirmBtn.disabled = false; + this.confirmBtn.innerHTML = 'Restore'; + } + } + + showStatus(msg, type) { + if (!this.statusEl) return; + this.statusEl.textContent = msg; + this.statusEl.className = `mt-3 small text-${type}`; + this.statusEl.style.display = ''; + } +} + +class PurgeManager { + constructor() { + this.keepDaysEl = document.getElementById('purge-keep-days'); + this.confirmDaysEl= document.getElementById('purge-confirm-days'); + this.confirmBtn = document.getElementById('purge-confirm-btn'); + this.statusEl = document.getElementById('purge-status'); + this.resultsEl = document.getElementById('purge-results'); + this.resultsBody = document.getElementById('purge-results-body'); + if (this.confirmBtn) { + this.confirmBtn.addEventListener('click', () => this.purge()); + } + window.purgeManager = this; + } + + prepareConfirm() { + const days = this.keepDaysEl ? this.keepDaysEl.value : '30'; + if (this.confirmDaysEl) { + this.confirmDaysEl.textContent = `${days} day${days === '1' ? '' : 's'}`; + } + } + + showStatus(msg, type) { + if (!this.statusEl) return; + this.statusEl.textContent = msg; + this.statusEl.className = `small text-${type}`; + this.statusEl.style.display = 'inline'; + } + + async purge() { + if (this.confirmBtn) this.confirmBtn.disabled = true; + const days = this.keepDaysEl ? parseInt(this.keepDaysEl.value, 10) : 30; + const modal = bootstrap.Modal.getInstance(document.getElementById('purgeModal')); + if (modal) modal.hide(); + this.showStatus('Purging…', 'secondary'); + if (this.resultsEl) this.resultsEl.style.display = 'none'; + try { + const resp = await fetch('/api/maintenance/purge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keep_days: days }), + }); + const data = await resp.json(); + if (!resp.ok) { + this.showStatus(`Error: ${data.error || resp.statusText}`, 'danger'); + return; + } + const deleted = data.deleted || {}; + const total = Object.values(deleted).reduce((s, v) => s + v, 0); + this.showStatus(`Done — ${total} row${total === 1 ? '' : 's'} deleted`, 'success'); + if (this.resultsBody && Object.keys(deleted).length > 0) { + this.resultsBody.innerHTML = Object.entries(deleted) + .map(([t, c]) => `${t}${c}`) + .join(''); + if (this.resultsEl) this.resultsEl.style.display = 'block'; + } + } catch (e) { + this.showStatus(`Error: ${e.message}`, 'danger'); + } finally { + if (this.confirmBtn) this.confirmBtn.disabled = false; + } + } +} + document.addEventListener('DOMContentLoaded', () => { new LogRotationManager(); new DbBackupManager(); new MaintenanceStatusManager(); + new RestoreManager(); + new PurgeManager(); }); {% endblock %} diff --git a/modules/web_viewer/templates/index.html b/modules/web_viewer/templates/index.html index bc97320..ab4c2d8 100644 --- a/modules/web_viewer/templates/index.html +++ b/modules/web_viewer/templates/index.html @@ -28,7 +28,11 @@ Online -

0

+

+ 0 +

Connected Clients @@ -316,11 +320,34 @@
-
+
Live Activity -
+
+ +
+ Filter: +
+ + +
+
+ + +
+
+ + +
+
+ + + 0
+ + + {% endblock %} {% block extra_js %} @@ -737,6 +797,43 @@ class ModernDashboard { } } + async loadConnectedClients() { + const loading = document.getElementById('connected-clients-loading'); + const empty = document.getElementById('connected-clients-empty'); + const table = document.getElementById('connected-clients-table'); + const tbody = document.getElementById('connected-clients-tbody'); + if (!loading) return; + loading.style.display = ''; + empty.style.display = 'none'; + table.style.display = 'none'; + tbody.innerHTML = ''; + try { + const resp = await fetch('/api/connected_clients'); + const clients = await resp.json(); + loading.style.display = 'none'; + if (!Array.isArray(clients) || clients.length === 0) { + empty.style.display = ''; + return; + } + table.style.display = ''; + for (const c of clients) { + const connAt = c.connected_at + ? new Date(c.connected_at * 1000).toLocaleTimeString() + : '—'; + const lastAct = c.last_activity + ? new Date(c.last_activity * 1000).toLocaleTimeString() + : '—'; + const tr = document.createElement('tr'); + tr.innerHTML = `${c.client_id}${connAt}${lastAct}`; + tbody.appendChild(tr); + } + } catch (e) { + loading.style.display = 'none'; + empty.style.display = ''; + empty.textContent = 'Failed to load client list.'; + } + } + showError(message) { const errorDiv = document.createElement('div'); errorDiv.className = 'alert alert-danger alert-dismissible fade show'; @@ -795,14 +892,35 @@ window.addEventListener('beforeunload', () => { return String(s || '').replace(/&/g,'&').replace(//g,'>'); } + // Track active type filters + const activeFilters = { packet: true, command: true, message: true }; + + function applyFilters() { + Array.from(feed.children).forEach(el => { + const t = el.dataset.type; + if (!t) return; // placeholder or unknown + el.style.display = activeFilters[t] ? '' : 'none'; + }); + } + + // Wire filter checkboxes + document.querySelectorAll('.live-filter-cb').forEach(cb => { + cb.addEventListener('change', () => { + activeFilters[cb.dataset.type] = cb.checked; + applyFilters(); + }); + }); + function addEntry(label, text, type) { if (paused) return; if (placeholder && placeholder.parentNode) placeholder.remove(); const ts = new Date().toLocaleTimeString(); const color = TYPE_COLORS[type] || '#6c757d'; const el = document.createElement('div'); + el.dataset.type = type; el.style.cssText = `border-left:3px solid ${color};padding:3px 8px;margin-bottom:3px;font-size:0.82rem;border-radius:2px;`; el.innerHTML = `${ts}${escHtml(label)} ${escHtml(text)}`; + if (!activeFilters[type]) el.style.display = 'none'; feed.insertBefore(el, feed.firstChild); total++; countBadge.textContent = total; @@ -821,6 +939,9 @@ window.addEventListener('beforeunload', () => { total = 0; countBadge.textContent = '0'; }; + window.scrollLiveFeed = function (dir) { + feed.scrollTop = dir === 'top' ? 0 : feed.scrollHeight; + }; const socket = io({ transports: ['websocket', 'polling'] }); diff --git a/modules/web_viewer/templates/realtime.html b/modules/web_viewer/templates/realtime.html index e443da7..56cdaa1 100644 --- a/modules/web_viewer/templates/realtime.html +++ b/modules/web_viewer/templates/realtime.html @@ -443,15 +443,38 @@
+ +
+
+
+ Show: +
+ + +
+
+ + +
+
+ + +
+
+
+
+
-
+
Command Stream
-
- Connected - +
+ Connecting… + + +
@@ -465,12 +488,14 @@
-
+
Packet Stream
-
- Active - +
+ Connecting… + + +
@@ -487,12 +512,14 @@
-
+
Live Channel Messages
-
- Active - +
+ Connecting… + + + @@ -707,14 +734,21 @@ {% endblock %} {% block extra_js %} -