fix: BUG-025/026/027/028/029 implementations and ruff/mypy refinements

BUG-025: send_channel_message retry logic on no_event_received
BUG-026: split_text_into_chunks and chunked dispatch in message_handler
BUG-027: test_weekly_on_wrong_day_does_not_run patch uses fake_now
BUG-028: byte_data = b"" initialised before try in decode_meshcore_packet
BUG-029: app.py db_path via self._config_base; realtime.html socket race
  fixed; base.html forceNew removed; ping_timeout 5 to 20s

Additional: ruff and mypy refinements across all modules; discord bridge,
telegram bridge, rate limiter, and service plugin updates
This commit is contained in:
Stacy Olivas
2026-03-17 17:46:27 -07:00
parent ba83acb064
commit c9cfcdfe00
66 changed files with 1086 additions and 241 deletions
+3 -3
View File
@@ -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}")
+2 -2
View File
@@ -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'))
+3 -3
View File
@@ -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"
+7 -7
View File
@@ -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()
+92 -15
View File
@@ -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 <command>'"
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)
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -113,7 +113,7 @@ class AqiCommand(BaseCommand):
region = self.default_state or self.default_country
return f"Usage: aqi <city|neighborhood|city country|lat,lon|help> - 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:
+1 -1
View File
@@ -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)
+5 -5
View File
@@ -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")
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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 "
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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)
+5 -3
View File
@@ -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
+20 -14
View File
@@ -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}")
+7 -6
View File
@@ -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, {})
+4 -4
View File
@@ -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:
+1 -1
View File
@@ -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
+20 -19
View File
@@ -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)
+20 -4
View File
@@ -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', '')
+1 -1
View File
@@ -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)
+5 -4
View File
@@ -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, {})
+3
View File
@@ -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.
@@ -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:
@@ -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)
@@ -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
@@ -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:
@@ -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:
+9 -5
View File
@@ -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
+3 -3
View File
@@ -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:
+11 -11
View File
@@ -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
+212 -24
View File
@@ -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": <int>|"all"}
Valid keep_days values: "all", 1, 7, 14, 30, 60, 90
Returns: {"deleted": {<table>: <count>, ...}}
"""
_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]
+57 -12
View File
@@ -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):
+4 -2
View File
@@ -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();
+316 -2
View File
@@ -204,7 +204,8 @@
<label class="form-label" for="db-backup-dir">Backup directory</label>
<input type="text" class="form-control" id="db-backup-dir"
placeholder="/data/backups">
<div class="form-text">Absolute path. The directory will be created if it does not exist.</div>
<div class="form-text">Absolute path. The directory must already exist.</div>
<div id="db-backup-dir-error" class="text-danger small mt-1" style="display:none"></div>
</div>
</div>
<div class="row g-3 mb-4">
@@ -222,6 +223,14 @@
<button type="button" class="btn btn-primary" id="save-db-backup-btn">
<i class="fas fa-save me-1"></i>Save
</button>
<button type="button" class="btn btn-secondary" id="backup-now-btn">
<i class="fas fa-database me-1"></i>Backup Now
</button>
<button type="button" class="btn btn-outline-warning" id="restore-btn"
data-bs-toggle="modal" data-bs-target="#restoreModal"
onclick="window.restoreManager && window.restoreManager.load()">
<i class="fas fa-undo me-1"></i>Restore
</button>
<span id="db-backup-status" class="ms-2 small" style="display:none;"></span>
</div>
</form>
@@ -255,6 +264,131 @@
</div>
</div>
</div>
<!-- Database Operations Card -->
<div class="container-fluid mt-4">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-trash-alt me-2"></i>Database Operations</h5>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Permanently delete old records from time-series tables
(<code>packet_stream</code>, <code>message_stats</code>,
<code>complete_contact_tracking</code>, <code>purging_log</code>,
<code>mesh_connections</code>, <code>daily_stats</code>).
This cannot be undone — back up first if needed.
</p>
<div class="row g-3 align-items-end">
<div class="col-auto">
<label class="form-label" for="purge-keep-days">Keep data for</label>
<select class="form-select" id="purge-keep-days" style="width:auto">
<option value="1">1 day</option>
<option value="7">7 days</option>
<option value="14">14 days</option>
<option value="30" selected>30 days</option>
<option value="60">60 days</option>
<option value="90">90 days</option>
</select>
</div>
<div class="col-auto">
<button type="button" class="btn btn-danger"
data-bs-toggle="modal" data-bs-target="#purgeModal"
onclick="window.purgeManager && window.purgeManager.prepareConfirm()">
<i class="fas fa-trash-alt me-1"></i>Purge Now
</button>
</div>
<div class="col-auto">
<span id="purge-status" class="small" style="display:none"></span>
</div>
</div>
<div id="purge-results" class="mt-3" style="display:none">
<h6 class="text-muted">Rows deleted:</h6>
<table class="table table-sm table-bordered" style="max-width:400px">
<thead><tr><th>Table</th><th>Deleted</th></tr></thead>
<tbody id="purge-results-body"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Purge Confirmation Modal -->
<div class="modal fade" id="purgeModal" tabindex="-1" aria-labelledby="purgeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="purgeModalLabel">
<i class="fas fa-exclamation-triangle me-2"></i>Confirm Purge
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>You are about to permanently delete records older than
<strong id="purge-confirm-days"></strong> from all time-series tables.</p>
<p class="text-danger"><strong>This cannot be undone.</strong>
Make sure you have a backup if needed.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="purge-confirm-btn">
<i class="fas fa-trash-alt me-1"></i>Delete Records
</button>
</div>
</div>
</div>
</div>
<!-- Restore Modal -->
<div class="modal fade" id="restoreModal" tabindex="-1" aria-labelledby="restoreModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="restoreModalLabel">
<i class="fas fa-undo me-2"></i>Restore Database
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Warning:</strong> Restoring will overwrite the active database.
The bot must be restarted after restore for changes to take effect.
</div>
<div class="mb-3">
<label class="form-label" for="restore-db-path">Backup file path</label>
<input type="text" class="form-control" id="restore-db-path"
placeholder="/path/to/backup.db">
</div>
<div id="restore-backup-list-section">
<p class="text-muted small mb-1">Available backups from configured backup directory:</p>
<div id="restore-backup-loading" class="text-center py-2" style="display:none">
<span class="spinner-border spinner-border-sm"></span>
</div>
<div id="restore-backup-empty" class="text-muted small" style="display:none">
No backups found in backup directory.
</div>
<table class="table table-sm table-hover mb-0" id="restore-backup-table" style="display:none">
<thead><tr><th>File</th><th>Size</th><th>Date</th><th></th></tr></thead>
<tbody id="restore-backup-tbody"></tbody>
</table>
</div>
<div id="restore-status" class="mt-3 small" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-warning" id="restore-confirm-btn">
<i class="fas fa-undo me-1"></i>Restore
</button>
</div>
</div>
</div>
</div>
{% 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 = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>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 = '<i class="fas fa-database me-1"></i>Backup Now';
}
}
}
async save() {
this.saveBtn.disabled = true;
this.saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>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 = `
<td><small>${b.name}</small></td>
<td><small>${b.size_mb} MB</small></td>
<td><small>${dt}</small></td>
<td><button class="btn btn-xs btn-outline-secondary btn-sm py-0"
onclick="document.getElementById('restore-db-path').value='${b.path}'">
Use</button></td>`;
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 = '<span class="spinner-border spinner-border-sm me-1"></span>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 = '<i class="fas fa-undo me-1"></i>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]) => `<tr><td><code>${t}</code></td><td>${c}</td></tr>`)
.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();
});
</script>
{% endblock %}
+124 -3
View File
@@ -28,7 +28,11 @@
<span class="status-indicator status-connected me-2"></span>
<span id="system-status" class="fw-bold">Online</span>
</div>
<h3 id="connected-clients" class="text-primary mb-1">0</h3>
<h3 class="text-primary mb-1">
<a href="#" id="connected-clients" class="text-primary text-decoration-none"
data-bs-toggle="modal" data-bs-target="#connectedClientsModal"
onclick="window.dashboard && window.dashboard.loadConnectedClients()">0</a>
</h3>
<small class="text-muted text-uppercase">Connected Clients</small>
</div>
@@ -316,11 +320,34 @@
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span><i class="fas fa-satellite-dish me-2"></i>Live Activity
<span id="live-dot" class="status-indicator status-disconnected ms-2" title="SocketIO connection"></span>
</span>
<div class="d-flex gap-2 align-items-center">
<div class="d-flex gap-2 align-items-center flex-wrap">
<!-- Type filters -->
<div class="d-flex gap-2 align-items-center" id="live-filters">
<small class="text-muted">Filter:</small>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input live-filter-cb" type="checkbox" id="filter-packet" data-type="packet" checked>
<label class="form-check-label" for="filter-packet" style="font-size:0.8rem;color:#fd7e14">Packets</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input live-filter-cb" type="checkbox" id="filter-command" data-type="command" checked>
<label class="form-check-label" for="filter-command" style="font-size:0.8rem;color:#198754">Commands</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input live-filter-cb" type="checkbox" id="filter-message" data-type="message" checked>
<label class="form-check-label" for="filter-message" style="font-size:0.8rem;color:#0dcaf0">Messages</label>
</div>
</div>
<!-- Scroll buttons -->
<button class="btn btn-sm btn-outline-secondary" id="live-scroll-top" onclick="scrollLiveFeed('top')" title="Scroll to newest">
<i class="fas fa-arrow-up"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" id="live-scroll-bottom" onclick="scrollLiveFeed('bottom')" title="Scroll to oldest">
<i class="fas fa-arrow-down"></i>
</button>
<span id="live-count" class="badge bg-secondary">0</span>
<button class="btn btn-sm btn-outline-secondary" id="live-pause-btn" onclick="toggleLivePause()">
<i class="fas fa-pause" id="live-pause-icon"></i>
@@ -344,6 +371,39 @@
</div>
</div>
<!-- Connected Clients Modal -->
<div class="modal fade" id="connectedClientsModal" tabindex="-1" aria-labelledby="connectedClientsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="connectedClientsModalLabel">
<i class="fas fa-users me-2"></i>Connected Clients
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="connected-clients-loading" class="text-center py-3">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="ms-2">Loading…</span>
</div>
<div id="connected-clients-empty" class="text-center text-muted py-3" style="display:none">
No clients currently connected.
</div>
<table class="table table-sm mb-0" id="connected-clients-table" style="display:none">
<thead>
<tr>
<th>Client ID</th>
<th>Connected At</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody id="connected-clients-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
{% 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 = `<td><code>${c.client_id}</code></td><td>${connAt}</td><td>${lastAct}</td>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// 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 = `<span class="text-muted me-2">${ts}</span><strong>${escHtml(label)}</strong> <span class="text-muted">${escHtml(text)}</span>`;
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'] });
+83 -24
View File
@@ -443,15 +443,38 @@
</div>
</div>
<!-- Stream Filters -->
<div class="row mb-3">
<div class="col-12">
<div class="d-flex align-items-center flex-wrap gap-2">
<small class="text-muted me-1">Show:</small>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input rt-filter-cb" type="checkbox" id="rt-filter-command" data-target="command-card" checked>
<label class="form-check-label" for="rt-filter-command" style="font-size:0.8rem;color:#6c757d">Commands</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input rt-filter-cb" type="checkbox" id="rt-filter-packet" data-target="packet-card" checked>
<label class="form-check-label" for="rt-filter-packet" style="font-size:0.8rem;color:#0dcaf0">Packets</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input rt-filter-cb" type="checkbox" id="rt-filter-message" data-target="message-card" checked>
<label class="form-check-label" for="rt-filter-message" style="font-size:0.8rem;color:#0d6efd">Messages</label>
</div>
</div>
</div>
</div>
<!-- Stream Controls -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card" id="command-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6><i class="fas fa-comments"></i> Command Stream</h6>
<div>
<span class="badge bg-success" id="command-status">Connected</span>
<button class="btn btn-sm btn-outline-secondary ms-2" onclick="clearCommands()">Clear</button>
<div class="d-flex align-items-center gap-1">
<span class="badge bg-warning" id="command-status">Connecting…</span>
<button class="btn btn-sm btn-outline-secondary ms-2" id="cmd-scroll-top" onclick="scrollStream('command-stream','top')" title="Scroll to newest"><i class="fas fa-arrow-up"></i></button>
<button class="btn btn-sm btn-outline-secondary" id="cmd-scroll-bottom" onclick="scrollStream('command-stream','bottom')" title="Scroll to oldest"><i class="fas fa-arrow-down"></i></button>
<button class="btn btn-sm btn-outline-secondary ms-1" onclick="clearCommands()">Clear</button>
</div>
</div>
<div class="card-body">
@@ -465,12 +488,14 @@
</div>
<div class="col-md-6">
<div class="card">
<div class="card" id="packet-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6><i class="fas fa-route"></i> Packet Stream</h6>
<div>
<span class="badge bg-success" id="packet-status">Active</span>
<button class="btn btn-sm btn-outline-secondary ms-2" onclick="clearPackets()">Clear</button>
<div class="d-flex align-items-center gap-1">
<span class="badge bg-warning" id="packet-status">Connecting…</span>
<button class="btn btn-sm btn-outline-secondary ms-2" id="pkt-scroll-top" onclick="scrollStream('packet-stream','top')" title="Scroll to newest"><i class="fas fa-arrow-up"></i></button>
<button class="btn btn-sm btn-outline-secondary" id="pkt-scroll-bottom" onclick="scrollStream('packet-stream','bottom')" title="Scroll to oldest"><i class="fas fa-arrow-down"></i></button>
<button class="btn btn-sm btn-outline-secondary ms-1" onclick="clearPackets()">Clear</button>
</div>
</div>
<div class="card-body">
@@ -487,12 +512,14 @@
<!-- Message Monitor -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card" id="message-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6><i class="fas fa-comment-dots"></i> Live Channel Messages</h6>
<div>
<span class="badge bg-success" id="message-status">Active</span>
<button class="btn btn-sm btn-outline-secondary ms-2" onclick="clearMessages()">Clear</button>
<div class="d-flex align-items-center gap-1">
<span class="badge bg-warning" id="message-status">Connecting…</span>
<button class="btn btn-sm btn-outline-secondary ms-2" id="msg-scroll-top" onclick="scrollStream('message-stream','top')" title="Scroll to newest"><i class="fas fa-arrow-up"></i></button>
<button class="btn btn-sm btn-outline-secondary" id="msg-scroll-bottom" onclick="scrollStream('message-stream','bottom')" title="Scroll to oldest"><i class="fas fa-arrow-down"></i></button>
<button class="btn btn-sm btn-outline-secondary ms-1" onclick="clearMessages()">Clear</button>
<button class="btn btn-sm btn-outline-secondary ms-1" id="msg-pause-btn" onclick="toggleMessagePause()">
<i class="fas fa-pause" id="msg-pause-icon"></i> Pause
</button>
@@ -707,14 +734,21 @@
{% endblock %}
{% block extra_js %}
<script type="module">
// Import meshcore-decoder for client-side packet decoding
import { decodePacket, MeshCoreKeyStore } from '/static/js/meshcore-browser.mjs';
// Make decoder available globally for use in functions
window.meshCoreDecoder = { decodePacket, MeshCoreKeyStore };
// Initialize Socket.IO connection
<script>
// Load meshcore-decoder asynchronously so it doesn't block socket init.
// Dynamic import() works in regular <script> tags on all modern browsers and
// avoids the <script type="module"> scope/timing issues that prevented
// socket.on('connect') from firing (the module deferred execution caused a
// race between two Socket.IO managers, with the module's socket never
// receiving its connect event).
import('/static/js/meshcore-browser.mjs').then(function(m) {
window.meshCoreDecoder = m;
window._MeshCoreKeyStore = m.MeshCoreKeyStore;
}).catch(function(e) {
console.warn('MeshCore decoder unavailable:', e);
});
// Initialize Socket.IO connection (plain io() with same defaults as base.html)
const socket = io();
// Connection status
@@ -754,7 +788,7 @@
for (const entry of channelEntries) {
try {
// Create a key store with just this one secret
const testKeyStore = new MeshCoreKeyStore({
const testKeyStore = new (window._MeshCoreKeyStore || class { addChannelSecrets(){} })({
channelSecrets: [entry.secret]
});
@@ -845,9 +879,9 @@
}
// Create the decoder key store with all channel secrets
decoderKeyStore = new MeshCoreKeyStore({
decoderKeyStore = window._MeshCoreKeyStore ? new window._MeshCoreKeyStore({
channelSecrets: channelSecrets
});
}) : null;
window.decoderKeyStore = decoderKeyStore;
let logMsg = `Initialized MeshCore decoder with ${channelSecrets.length} channel keys (Public`;
@@ -923,6 +957,9 @@
// Socket event handlers
socket.on('connect', function() {
console.log('Connected to server');
updateStatus('command-status', 'Connected', 'success');
updateStatus('packet-status', 'Connected', 'success');
updateStatus('message-status', 'Connected', 'success');
socket.emit('subscribe_commands');
socket.emit('subscribe_packets');
socket.emit('subscribe_messages');
@@ -1017,6 +1054,8 @@
const hops = (data.hops != null && data.hops !== '' && data.hops !== 255)
? `<small class="text-muted ms-1">${data.hops} hop${data.hops !== 1 ? 's' : ''}</small>` : '';
const channelLabel = data.channel && !data.is_dm
? `<span class="text-info me-1">[#${escapeHtml(data.channel)}]</span>` : '';
const entry = document.createElement('div');
entry.className = 'stream-entry p-2 mb-1 rounded';
entry.style.borderLeftColor = data.is_dm ? '#6f42c1' : '#0dcaf0';
@@ -1025,7 +1064,7 @@
<span>${ch}${dm}<strong>${escapeHtml(data.sender || '?')}</strong>${snr}${hops}</span>
<small class="text-muted">${ts}</small>
</div>
<div class="text-break">${escapeHtml(data.content || '')}</div>
<div class="text-break">${channelLabel}${escapeHtml(data.content || '')}</div>
`;
container.insertBefore(entry, container.firstChild);
@@ -1048,6 +1087,26 @@
window.clearMessages = clearMessages;
window.toggleMessagePause = toggleMessagePause;
window.scrollStream = function(containerId, dir) {
const c = document.getElementById(containerId);
if (!c) return;
if (dir === 'top') {
c.scrollTop = 0;
} else {
c.scrollTop = c.scrollHeight;
}
};
// Stream panel visibility filters
document.querySelectorAll('.rt-filter-cb').forEach(function(cb) {
cb.addEventListener('change', function() {
const card = document.getElementById(this.dataset.target);
if (card) {
card.style.display = this.checked ? '' : 'none';
}
});
});
// Ping mechanism to keep connection alive
let pingInterval = null;