docs: Add docstrings and type hints across modules for improved clarity and maintainability.

This commit is contained in:
agessaman
2026-01-01 20:12:49 -08:00
parent e91370672e
commit a32fe0dcfd
34 changed files with 2788 additions and 776 deletions
+128 -32
View File
@@ -21,7 +21,13 @@ from .utils import check_internet_connectivity_async, format_keyword_response_wi
@dataclass
class InternetStatusCache:
"""Thread-safe cache for internet connectivity status"""
"""Thread-safe cache for internet connectivity status.
Attributes:
has_internet: Boolean indicating if internet is available.
timestamp: Timestamp of the last check.
lock: Asyncio lock for thread-safe operations.
"""
has_internet: bool
timestamp: float
lock: Optional[asyncio.Lock] = None
@@ -31,12 +37,24 @@ class InternetStatusCache:
self.lock = asyncio.Lock()
def is_valid(self, cache_duration: float) -> bool:
"""Check if cache entry is still valid"""
"""Check if cache entry is still valid.
Args:
cache_duration: Duration in seconds for which the cache is valid.
Returns:
bool: True if the cache is still valid, False otherwise.
"""
return time.time() - self.timestamp < cache_duration
class CommandManager:
"""Manages all bot commands and responses using dynamic plugin loading"""
"""Manages all bot commands and responses using dynamic plugin loading.
This class handles loading commands from plugins, matching messages against
commands and keywords, checking permissions and rate limits, and executing
command logic. It also manages channel monitoring and banned users.
"""
def __init__(self, bot):
self.bot = bot
@@ -66,11 +84,15 @@ class CommandManager:
await asyncio.sleep(self.bot.tx_delay_ms / 1000.0)
async def _check_rate_limits(self) -> Tuple[bool, str]:
"""
Check all rate limits before sending.
"""Check all rate limits before sending.
Checks both the user-specific rate limits and the global bot transmission
limits. Also applies transmission delays if configured.
Returns:
Tuple of (can_send: bool, reason: str)
Tuple[bool, str]: A tuple containing:
- can_send: True if the message can be sent, False otherwise.
- reason: Reason string if rate limited, empty string otherwise.
"""
# Check user rate limiter
if not self.bot.rate_limiter.can_send():
@@ -90,17 +112,16 @@ class CommandManager:
return True, ""
def _handle_send_result(self, result, operation_name: str, target: str, used_retry_method: bool = False) -> bool:
"""
Handle result from message send operations.
"""Handle result from message send operations.
Args:
result: Result from meshcore send operation
operation_name: "DM" or "Channel message"
target: Recipient name or channel name
used_retry_method: True if send_msg_with_retry was used (affects logging)
result: Result object from meshcore send operation.
operation_name: Name of the operation ("DM" or "Channel message").
target: Recipient name or channel name for logging.
used_retry_method: True if send_msg_with_retry was used (affects logging).
Returns:
bool: True if send succeeded, False otherwise
bool: True if send succeeded (ACK received or sent successfully), False otherwise.
"""
if not result:
if used_retry_method:
@@ -148,7 +169,11 @@ class CommandManager:
return True
def load_keywords(self) -> Dict[str, str]:
"""Load keywords from config"""
"""Load keywords from config.
Returns:
Dict[str, str]: Dictionary mapping keywords to response strings.
"""
keywords = {}
if self.bot.config.has_section('Keywords'):
for keyword, response in self.bot.config.items('Keywords'):
@@ -180,7 +205,15 @@ class CommandManager:
return [channel.strip() for channel in channels.split(',') if channel.strip()]
def format_keyword_response(self, response_format: str, message: MeshMessage) -> str:
"""Format a keyword response string with message data"""
"""Format a keyword response string with message data.
Args:
response_format: The response string format with placeholders.
message: The message object containing context for placeholders.
Returns:
str: The formatted response string.
"""
# Use shared formatting function from utils
return format_keyword_response_with_placeholders(
response_format,
@@ -190,7 +223,17 @@ class CommandManager:
)
def check_keywords(self, message: MeshMessage) -> List[tuple]:
"""Check message content for keywords and return matching responses"""
"""Check message content for keywords and return matching responses.
Evaluates the message against configured keywords, custom syntax patterns,
and command triggers.
Args:
message: The incoming message to check.
Returns:
List[tuple]: List of (trigger, response) tuples for matched keywords.
"""
matches = []
# Strip exclamation mark if present (for command-style messages)
content = message.content.strip()
@@ -282,7 +325,14 @@ class CommandManager:
return matches
async def handle_advert_command(self, message: MeshMessage):
"""Handle the advert command from DM"""
"""Handle the advert command from DM.
Executes the advert command specifically, ensuring proper stat recording
and response handling.
Args:
message: The message triggering the advert command.
"""
command = self.commands['advert']
success = await command.execute(message)
@@ -303,7 +353,17 @@ class CommandManager:
stats_command.record_command(message, 'advert', response_sent)
async def send_dm(self, recipient_id: str, content: str) -> bool:
"""Send a direct message using meshcore-cli command"""
"""Send a direct message using meshcore-cli command.
Handles contact lookup, rate limiting, and uses retry logic if available.
Args:
recipient_id: The recipient's name or ID.
content: The message content to send.
Returns:
bool: True if sent successfully, False otherwise.
"""
if not self.bot.connected or not self.bot.meshcore:
return False
@@ -368,7 +428,17 @@ class CommandManager:
return False
async def send_channel_message(self, channel: str, content: str) -> bool:
"""Send a channel message using meshcore-cli command"""
"""Send a channel message using meshcore-cli command.
Resolves channel names to numbers and handles rate limiting.
Args:
channel: The channel name (e.g., "LongFast").
content: The message content to send.
Returns:
bool: True if sent successfully, False otherwise.
"""
if not self.bot.connected or not self.bot.meshcore:
return False
@@ -403,7 +473,15 @@ class CommandManager:
return False
def get_help_for_command(self, command_name: str, message: MeshMessage = None) -> str:
"""Get help text for a specific command (LoRa-friendly compact format)"""
"""Get help text for a specific command (LoRa-friendly compact format).
Args:
command_name: The name of the command to retrieve help for.
message: Optional message object for context-aware help (e.g. translated).
Returns:
str: The help text for the command.
"""
# Special handling for common help requests
if command_name.lower() in ['commands', 'list', 'all']:
# User is asking for a list of commands, show general help
@@ -531,7 +609,18 @@ class CommandManager:
return commands_list
async def send_response(self, message: MeshMessage, content: str) -> bool:
"""Unified method for sending responses to users"""
"""Unified method for sending responses to users.
Automatically determines whether to send a DM or channel message based
on the incoming message type.
Args:
message: The original message being responded to.
content: The response content.
Returns:
bool: True if response was sent successfully, False otherwise.
"""
try:
# Store the response content for web viewer capture
if hasattr(self, '_last_response'):
@@ -548,7 +637,14 @@ class CommandManager:
return False
async def execute_commands(self, message):
"""Execute command objects that handle their own responses"""
"""Execute command objects that handle their own responses.
Identifies and executes commands that were not handled by simple keyword
matching, managing permissions, internet checks, and error handling.
Args:
message: The message triggering the command execution.
"""
# Strip exclamation mark if present (for command-style messages)
content = message.content.strip()
if content.startswith('!'):
@@ -696,13 +792,13 @@ class CommandManager:
return
def _check_internet_cached(self) -> bool:
"""
Check internet connectivity with caching to avoid checking on every command.
Uses synchronous check for keyword matching.
Note: This is a synchronous method, but the cache itself is thread-safe.
"""Check internet connectivity with caching to avoid checking on every command.
Uses synchronous check for keyword matching. Note: This is a synchronous
method, but the cache itself is thread-safe.
Returns:
True if internet is available, False otherwise
bool: True if internet is available, False otherwise.
"""
current_time = time.time()
@@ -721,13 +817,13 @@ class CommandManager:
return has_internet
async def _check_internet_cached_async(self) -> bool:
"""
Check internet connectivity with caching to avoid checking on every command.
Uses async check for command execution.
Thread-safe with asyncio.Lock to prevent race conditions.
"""Check internet connectivity with caching to avoid checking on every command.
Uses async check for command execution. Thread-safe with asyncio.Lock
to prevent race conditions.
Returns:
True if internet is available, False otherwise
bool: True if internet is available, False otherwise.
"""
# Use lock to prevent race conditions when checking/updating cache
async with self._internet_cache.lock:
+33 -3
View File
@@ -10,7 +10,12 @@ from ..models import MeshMessage
class AdvertCommand(BaseCommand):
"""Handles the advert command"""
"""Handles the advert command.
This command allows users to manually trigger a flood advertisement
to help propagate their node information across the mesh network.
It enforces a strict cooldown to prevent network congestion.
"""
# Plugin metadata
name = "advert"
@@ -21,10 +26,25 @@ class AdvertCommand(BaseCommand):
category = "special"
def get_help_text(self) -> str:
"""Get help text for the advert command.
Returns:
str: The help text for this command.
"""
return self.translate('commands.advert.description')
def can_execute(self, message: MeshMessage) -> bool:
"""Check if advert command can be executed"""
"""Check if advert command can be executed.
Verifies both the standard command cooldowns and checks against the
bot's global last advertisement time.
Args:
message: The message triggering the command.
Returns:
bool: True if the command can be executed, False otherwise.
"""
# Use the base class cooldown check
if not super().can_execute(message):
return False
@@ -38,7 +58,17 @@ class AdvertCommand(BaseCommand):
return True
async def execute(self, message: MeshMessage) -> bool:
"""Execute the advert command"""
"""Execute the advert command.
Sends a flood advertisement if the cooldown has passed. If on cooldown,
informs the user of the remaining time.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully (including cooldown notice), False otherwise.
"""
try:
# Check if enough time has passed since last advert (1 hour)
current_time = time.time()
+184 -38
View File
@@ -66,7 +66,14 @@ UNIT_STATUS = {
def _derive_key(salt: bytes) -> bytes:
"""Derive AES key from the obfuscated password."""
"""Derive AES key from the obfuscated password.
Args:
salt: The salt bytes to use for derivation.
Returns:
bytes: The derived 32-byte key.
"""
e = "CommonIncidents"
password = e[13] + e[1] + e[2] + "brady" + "5" + "r" + e.lower()[6] + e[5] + "gs"
@@ -85,7 +92,14 @@ def _derive_key(salt: bytes) -> bytes:
def _decrypt(data: dict) -> dict:
"""Decrypt PulsePoint's encrypted response."""
"""Decrypt PulsePoint's encrypted response.
Args:
data: The encrypted data dictionary from the API.
Returns:
dict: The decrypted JSON data.
"""
ct = base64.b64decode(data["ct"])
iv = bytes.fromhex(data["iv"])
salt = bytes.fromhex(data["s"])
@@ -100,8 +114,15 @@ def _decrypt(data: dict) -> dict:
return json.loads(out)
def _parse_time(iso_str: str) -> datetime:
"""Parse ISO timestamp to datetime and convert to local time."""
def _parse_time(iso_str: str) -> Optional[datetime]:
"""Parse ISO timestamp to datetime and convert to local time.
Args:
iso_str: ISO formatted timestamp string.
Returns:
Optional[datetime]: Parsed timezone-aware datetime, or None if invalid.
"""
if not iso_str:
return None
try:
@@ -115,7 +136,14 @@ def _parse_time(iso_str: str) -> datetime:
def _time_ago(dt: datetime) -> str:
"""Format datetime as relative time string."""
"""Format datetime as relative time string (e.g., '5m ago').
Args:
dt: The datetime to compare against current time.
Returns:
str: Relative time string.
"""
if not dt:
return ""
@@ -138,7 +166,11 @@ def _time_ago(dt: datetime) -> str:
class AlertCommand(BaseCommand):
"""Handles alert/incident commands using PulsePoint API"""
"""Handles alert/incident commands using PulsePoint API.
Retrieves and displays active fire and emergency incidents for specified
locations (city, county, zipcode, or coordinates).
"""
# Plugin metadata
name = "alert"
@@ -163,7 +195,14 @@ class AlertCommand(BaseCommand):
self.max_incident_age_hours = self.get_config_value('Alert_Command', 'max_incident_age_hours', fallback=24.0, value_type='float')
def can_execute(self, message: MeshMessage) -> bool:
"""Check if this command can be executed with the given message"""
"""Check if this command can be executed with the given message.
Args:
message: The message triggering the command.
Returns:
bool: True if command is enabled and checks pass, False otherwise.
"""
# Check if alert command is enabled
alert_enabled = self.get_config_value('Alert_Command', 'alert_enabled', fallback=True, value_type='bool')
if not alert_enabled:
@@ -173,7 +212,11 @@ class AlertCommand(BaseCommand):
return super().can_execute(message)
def _load_agencies(self) -> Tuple[Dict[str, str], Dict[str, str]]:
"""Load agency IDs from config, separating cities and counties"""
"""Load agency IDs from config, separating cities and counties.
Returns:
Tuple[Dict[str, str], Dict[str, str]]: Tuple of (cities_map, counties_map).
"""
cities = {}
counties = {}
if self.bot.config.has_section('Alert_Command'):
@@ -196,11 +239,26 @@ class AlertCommand(BaseCommand):
return cities, counties
def _normalize_location_key(self, location: str) -> str:
"""Normalize location name to match config key format (spaces -> underscores)"""
"""Normalize location name to match config key format (spaces -> underscores).
Args:
location: The raw location string.
Returns:
str: Normalized location string.
"""
return location.lower().replace(' ', '_')
def _get_agency_ids(self, location: str = None, location_type: str = None) -> Optional[str]:
"""Get agency IDs for a city or county, or default to all configured agencies"""
"""Get agency IDs for a city or county, or default to all configured agencies.
Args:
location: Name of the city or county.
location_type: Type of location ('city' or 'county').
Returns:
Optional[str]: Comma-separated agency IDs, or None if specific location not found.
"""
if location:
location_lower = location.lower()
location_normalized = self._normalize_location_key(location)
@@ -263,7 +321,14 @@ class AlertCommand(BaseCommand):
return ",".join(all_agencies)
def _fetch_incidents(self, agency_ids: str) -> List[Dict]:
"""Fetch active incidents from PulsePoint"""
"""Fetch active incidents from PulsePoint.
Args:
agency_ids: Comma-separated string of agency IDs.
Returns:
List[Dict]: List of incident dictionaries.
"""
url = "https://api.pulsepoint.org/v1/webapp"
params = {"resource": "incidents", "agencyid": agency_ids}
headers = {
@@ -357,12 +422,15 @@ class AlertCommand(BaseCommand):
return []
def _parse_query(self, query: str) -> Tuple[str, Optional[str], Optional[float], Optional[float]]:
"""
Parse query string to determine search type.
"""Parse query string to determine search type.
Args:
query: The raw query string from the user.
Returns:
Tuple of (query_type, location, lat, lon)
query_type: "zipcode", "coordinates", "street_city", "city", "county"
Tuple[str, Optional[str], Optional[float], Optional[float]]:
Tuple of (query_type, location, lat, lon).
query_type can be: "zipcode", "coordinates", "street_city", "city", "county".
"""
query = query.strip()
@@ -444,7 +512,15 @@ class AlertCommand(BaseCommand):
return ("city", query, None, None)
def _match_street_name(self, incidents: List[Dict], street_query: str) -> Tuple[List[Dict], List[Dict]]:
"""Split incidents into matched and unmatched by street name"""
"""Split incidents into matched and unmatched by street name.
Args:
incidents: List of incidents to filter.
street_query: Street name to search for.
Returns:
Tuple[List[Dict], List[Dict]]: (matched_incidents, unmatched_incidents).
"""
street_lower = street_query.lower().strip()
matched = []
unmatched = []
@@ -461,7 +537,15 @@ class AlertCommand(BaseCommand):
return matched, unmatched
def _matches_city(self, inc: Dict, city_query: str) -> bool:
"""Check if incident matches the city name by substring matching on address field"""
"""Check if incident matches the city name by substring matching on address field.
Args:
inc: Incident dictionary.
city_query: City name to check.
Returns:
bool: True if matched, False otherwise.
"""
city_query_lower = city_query.lower().strip()
address = inc.get("address", "").lower().strip()
city = inc.get("city", "").lower().strip()
@@ -470,14 +554,19 @@ class AlertCommand(BaseCommand):
return city_query_lower in address
def _get_city_match_priority(self, inc: Dict, city_query: str) -> int:
"""
Get priority score for city match (higher = better match).
Returns 0 if no match, higher numbers for better matches.
"""Get priority score for city match (higher = better match).
We prioritize matches where the city name appears at the end of the address
(after a comma), as this is the most reliable indicator. The city field
can be inaccurate (e.g., showing "SEATTLE" for addresses in King County
but not actually in Seattle).
Args:
inc: Incident dictionary.
city_query: City name to match.
Returns:
int: Priority score (2=suffix match, 1=substring match, 0=no match).
"""
city_query_lower = city_query.lower().strip()
address = inc.get("address", "").lower().strip()
@@ -500,7 +589,15 @@ class AlertCommand(BaseCommand):
return 0
def _match_city_name(self, incidents: List[Dict], city_query: str) -> Tuple[List[Dict], List[Dict]]:
"""Split incidents into matched and unmatched by city name using simple substring matching on address"""
"""Split incidents into matched and unmatched by city name.
Args:
incidents: List of incidents to filter.
city_query: City name to filter by.
Returns:
Tuple[List[Dict], List[Dict]]: (matched_incidents, unmatched_incidents).
"""
matched = []
unmatched = []
@@ -513,7 +610,14 @@ class AlertCommand(BaseCommand):
return matched, unmatched
def _sort_by_time(self, incidents: List[Dict]) -> List[Dict]:
"""Sort incidents by time (most recent first)"""
"""Sort incidents by time (most recent first).
Args:
incidents: List of incidents to sort.
Returns:
List[Dict]: Sorted list of incidents.
"""
def get_time_key(inc):
time = inc.get("time")
if time is None:
@@ -526,9 +630,16 @@ class AlertCommand(BaseCommand):
return sorted(incidents, key=get_time_key, reverse=True)
def _sort_by_distance_then_time(self, incidents: List[Dict], lat: float, lon: float, max_distance: float = None) -> List[Dict]:
"""
Sort incidents by distance first, then by time (most recent first) within same distance.
This maintains distance ordering while sorting by time as secondary criteria.
"""Sort incidents by distance first, then by time (most recent first) within same distance.
Args:
incidents: List of incidents to sort.
lat: Reference latitude.
lon: Reference longitude.
max_distance: Optional max distance in km to filter.
Returns:
List[Dict]: Sorted list of incidents.
"""
scored_incidents = []
for inc in incidents:
@@ -557,7 +668,14 @@ class AlertCommand(BaseCommand):
return sorted(scored_incidents, key=lambda x: (x.get("_distance", float('inf')), -x.get("_time_key", datetime.min).timestamp()))
def _has_valid_coordinates(self, inc: Dict) -> bool:
"""Check if incident has valid coordinates"""
"""Check if incident has valid coordinates.
Args:
inc: Incident dictionary.
Returns:
bool: True if coordinates are valid and non-zero, False otherwise.
"""
inc_lat = inc.get("latitude", 0)
inc_lon = inc.get("longitude", 0)
# Valid if both are non-zero and within valid ranges
@@ -565,17 +683,16 @@ class AlertCommand(BaseCommand):
-90 <= inc_lat <= 90 and -180 <= inc_lon <= 180)
def _sort_by_distance(self, incidents: List[Dict], lat: float, lon: float, max_distance: float = None) -> List[Dict]:
"""
Sort incidents by distance from given coordinates.
"""Sort incidents by distance from given coordinates.
Args:
incidents: List of incident dicts
lat: Target latitude
lon: Target longitude
max_distance: Optional maximum distance in km (incidents beyond this are excluded)
incidents: List of incident dicts.
lat: Target latitude.
lon: Target longitude.
max_distance: Optional maximum distance in km (incidents beyond this are excluded).
Returns:
Sorted list of incidents (closest first). Only includes incidents with valid coordinates.
List[Dict]: Sorted list of incidents (closest first). Only includes incidents with valid coordinates.
"""
scored_incidents = []
for inc in incidents:
@@ -596,7 +713,14 @@ class AlertCommand(BaseCommand):
return sorted(scored_incidents, key=lambda x: x.get("_distance", float('inf')))
def _format_incident_compact(self, inc: Dict) -> str:
"""Format a single incident in compact format"""
"""Format a single incident in compact format.
Args:
inc: Incident dictionary.
Returns:
str: Formatted incident string for display.
"""
# Get first unit with status icon
unit_str = ""
if inc.get("units"):
@@ -618,7 +742,15 @@ class AlertCommand(BaseCommand):
return f"{inc['type']}: {inc['street']}{city_part}{time_part}{unit_str}"
def _format_response(self, incidents: List[Dict], max_length: int = 130) -> str:
"""Format incidents into a single message, limiting to max_length"""
"""Format incidents into a single message, limiting to max_length.
Args:
incidents: List of incidents to format.
max_length: Maximum length of the output string (default 130 for LoRa).
Returns:
str: Formatted response string.
"""
if not incidents:
return "🚨 No active incidents"
@@ -682,8 +814,13 @@ class AlertCommand(BaseCommand):
return final_message
async def _send_all_response(self, message: MeshMessage, incidents: List[Dict]):
"""Send up to 10 incidents in multiple messages, grouping efficiently"""
async def _send_all_response(self, message: MeshMessage, incidents: List[Dict]) -> None:
"""Send up to 10 incidents in multiple messages, grouping efficiently.
Args:
message: The message to respond to.
incidents: List of incidents to send.
"""
import asyncio
if not incidents:
@@ -742,7 +879,16 @@ class AlertCommand(BaseCommand):
await asyncio.sleep(2.0)
async def execute(self, message: MeshMessage) -> bool:
"""Execute the alert command"""
"""Execute the alert command.
Parses query, fetches incidents, filters/sorts, and sends response.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
content = message.content.strip()
# Parse command
+137 -29
View File
@@ -11,6 +11,7 @@ from geopy.geocoders import Nominatim
from ...utils import rate_limited_nominatim_geocode_sync, rate_limited_nominatim_reverse_sync, get_nominatim_geocoder, geocode_city_sync
from ..base_command import BaseCommand
from ...models import MeshMessage
from typing import Any, List, Optional, Tuple, Union
class GlobalWxCommand(BaseCommand):
@@ -27,7 +28,12 @@ class GlobalWxCommand(BaseCommand):
ERROR_FETCHING_DATA = "ERROR_FETCHING_DATA" # Placeholder, will use translate()
NO_ALERTS = "No weather alerts available"
def __init__(self, bot):
def __init__(self, bot: Any):
"""Initialize the global weather command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
self.url_timeout = 10 # seconds
@@ -58,10 +64,22 @@ class GlobalWxCommand(BaseCommand):
self.db_manager = bot.db_manager
def get_help_text(self) -> str:
"""Get help text for the command.
Returns:
str: Help text string.
"""
return self.translate('commands.gwx.help')
def matches_keyword(self, message: MeshMessage) -> bool:
"""Check if message starts with a weather keyword"""
"""Check if message starts with a weather keyword.
Args:
message: The received message.
Returns:
bool: True if message matches a keyword, False otherwise.
"""
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
@@ -73,7 +91,14 @@ class GlobalWxCommand(BaseCommand):
async def execute(self, message: MeshMessage) -> bool:
"""Execute the weather command"""
"""Execute the weather command.
Args:
message: The received message.
Returns:
bool: True if execution was successful.
"""
content = message.content.strip()
# Parse the command to extract location and forecast type
@@ -145,14 +170,17 @@ class GlobalWxCommand(BaseCommand):
await self.send_response(message, self.translate('commands.gwx.error', error=str(e)))
return True
async def get_weather_for_location(self, location: str, forecast_type: str = "default", num_days: int = 7, message: MeshMessage = None) -> str:
"""Get weather data for any global location
async def get_weather_for_location(self, location: str, forecast_type: str = "default", num_days: int = 7, message: MeshMessage = None) -> Union[str, Tuple[str, str, str]]:
"""Get weather data for any global location.
Args:
location: The location (city name, etc.)
forecast_type: "default", "tomorrow", or "multiday"
num_days: Number of days for multiday forecast (2-7)
message: The MeshMessage for dynamic length calculation
location: The location (city name, etc.).
forecast_type: "default", "tomorrow", or "multiday".
num_days: Number of days for multiday forecast (2-7).
message: The MeshMessage for dynamic length calculation.
Returns:
Union[str, Tuple[str, str, str]]: Format string or tuple for multi-message response.
"""
try:
# Convert location to lat/lon with address details
@@ -193,11 +221,17 @@ class GlobalWxCommand(BaseCommand):
return self.translate('commands.gwx.error', error=str(e))
def geocode_location(self, location: str) -> tuple:
"""Convert location string to lat/lon with address details
"""Convert location string to lat/lon with address details.
Handles both coordinate strings (lat,lon) and city names.
Uses geocode_city_sync for proper default state/country handling,
which prioritizes locations in the configured default state/country.
Args:
location: Location string (e.g., "Seattle" or "47.6,-122.3").
Returns:
tuple: (lat, lon, address_info, geocode_result) or (None, None, None, None) on failure.
"""
try:
# Check if location is coordinates (decimal numbers separated by comma, with optional spaces)
@@ -269,8 +303,17 @@ class GlobalWxCommand(BaseCommand):
self.logger.error(f"Error geocoding location {location}: {e}")
return None, None, None, None
def _format_location_display(self, address_info: dict, geocode_result, fallback: str) -> str:
"""Format location name for display from address info - returns 'City, CountryCode' format"""
def _format_location_display(self, address_info: dict, geocode_result: Any, fallback: str) -> str:
"""Format location name for display from address info - returns 'City, CountryCode' format.
Args:
address_info: Dictionary containing address details.
geocode_result: Full geocode result object.
fallback: Fallback location string if detailed info is missing.
Returns:
str: Formatted location string (e.g., "Seattle, US").
"""
# Get country code first (prefer this over full country name)
country_code = ''
if address_info:
@@ -361,7 +404,14 @@ class GlobalWxCommand(BaseCommand):
return fallback.title()
def _get_state_abbreviation(self, state: str) -> str:
"""Convert full state name to abbreviation"""
"""Convert full state name to abbreviation.
Args:
state: Full state name (e.g., "Washington").
Returns:
str: Two-letter state abbreviation (e.g., "WA") or original string if not found.
"""
state_map = {
'Washington': 'WA', 'California': 'CA', 'New York': 'NY', 'Texas': 'TX',
'Florida': 'FL', 'Illinois': 'IL', 'Pennsylvania': 'PA', 'Ohio': 'OH',
@@ -380,14 +430,17 @@ class GlobalWxCommand(BaseCommand):
return state_map.get(state, state)
def get_open_meteo_weather(self, lat: float, lon: float, forecast_type: str = "default", num_days: int = 7, message: MeshMessage = None) -> str:
"""Get weather forecast from Open-Meteo API
"""Get weather forecast from Open-Meteo API.
Args:
lat: Latitude
lon: Longitude
forecast_type: "default", "tomorrow", or "multiday"
num_days: Number of days for multiday forecast (2-7)
message: The MeshMessage for dynamic length calculation
lat: Latitude.
lon: Longitude.
forecast_type: "default", "tomorrow", or "multiday".
num_days: Number of days for multiday forecast (2-7).
message: The MeshMessage for dynamic length calculation.
Returns:
str: Formatted weather string or error message.
"""
# Get max message length dynamically
max_length = self.get_max_message_length(message) if message else 130
@@ -592,7 +645,14 @@ class GlobalWxCommand(BaseCommand):
return self.translate('commands.gwx.error_fetching')
def format_tomorrow_forecast(self, data: dict) -> str:
"""Format a detailed forecast for tomorrow"""
"""Format a detailed forecast for tomorrow.
Args:
data: Weather data dictionary from Open-Meteo.
Returns:
str: Formatted tomorrow forecast string.
"""
try:
daily = data.get('daily', {})
if not daily or len(daily.get('temperature_2m_max', [])) < 2:
@@ -643,7 +703,15 @@ class GlobalWxCommand(BaseCommand):
return self.translate('commands.gwx.tomorrow_error')
def format_multiday_forecast(self, data: dict, num_days: int = 7) -> str:
"""Format a less detailed multi-day forecast summary"""
"""Format a less detailed multi-day forecast summary.
Args:
data: Weather data dictionary from Open-Meteo.
num_days: Number of days to include in forecast.
Returns:
str: Formatted multi-day forecast string (newlines separate days).
"""
try:
daily = data.get('daily', {})
if not daily:
@@ -700,7 +768,14 @@ class GlobalWxCommand(BaseCommand):
return self.translate('commands.gwx.multiday_error', num_days=num_days)
def _count_display_width(self, text: str) -> int:
"""Count display width of text, accounting for emojis which may take 2 display units"""
"""Count display width of text, accounting for emojis which may take 2 display units.
Args:
text: Text to measure.
Returns:
int: Estimated display width.
"""
import re
# Count regular characters
width = len(text)
@@ -723,8 +798,13 @@ class GlobalWxCommand(BaseCommand):
width += len(emoji_matches)
return width
async def _send_multiday_forecast(self, message: MeshMessage, forecast_text: str):
"""Send multi-day forecast response, splitting into multiple messages if needed"""
async def _send_multiday_forecast(self, message: MeshMessage, forecast_text: str) -> None:
"""Send multi-day forecast response, splitting into multiple messages if needed.
Args:
message: The original message (for reply context).
forecast_text: The full forecast text (lines separated by \n).
"""
import asyncio
# Get max message length dynamically
@@ -788,7 +868,14 @@ class GlobalWxCommand(BaseCommand):
await self.send_response(message, current_message)
def _degrees_to_direction(self, degrees: float) -> str:
"""Convert wind direction in degrees to compass direction with emoji"""
"""Convert wind direction in degrees to compass direction with emoji.
Args:
degrees: Wind direction in degrees.
Returns:
str: Compass direction string with emoji (e.g., "⬆️N").
"""
if degrees is None:
return ""
@@ -808,7 +895,14 @@ class GlobalWxCommand(BaseCommand):
return "⬆️N" # Default to North
def _get_weather_description(self, code: int) -> str:
"""Convert WMO weather code to description"""
"""Convert WMO weather code to description.
Args:
code: WMO weather code.
Returns:
str: Weather description.
"""
# Try to get from translations first
key = f"commands.gwx.weather_descriptions.{code}"
description = self.translate(key)
@@ -851,7 +945,14 @@ class GlobalWxCommand(BaseCommand):
return description
def _get_weather_emoji(self, code: int) -> str:
"""Convert WMO weather code to emoji"""
"""Convert WMO weather code to emoji.
Args:
code: WMO weather code.
Returns:
str: Weather emoji.
"""
emoji_map = {
0: "☀️", # Clear
1: "🌤️", # Mostly Clear
@@ -885,8 +986,15 @@ class GlobalWxCommand(BaseCommand):
return emoji_map.get(code, "🌤️")
def _check_extreme_conditions(self, weather_text: str) -> str:
"""Check for extreme weather conditions that warrant warnings"""
def _check_extreme_conditions(self, weather_text: str) -> Optional[str]:
"""Check for extreme weather conditions that warrant warnings.
Args:
weather_text: The formatted weather text to check.
Returns:
Optional[str]: Warning text if conditions found, None otherwise.
"""
warnings = []
# Extract temperature from weather text
+67 -15
View File
@@ -12,7 +12,11 @@ from ..security_utils import validate_pubkey_format
class AnnouncementsCommand(BaseCommand):
"""Handles announcements command for sending messages to channels"""
"""Handles announcements command for sending messages to channels.
Allows authorized users to trigger pre-configured announcements to be sent
to specific channels. Requires specific ACL access and operates via DM only.
"""
# Plugin metadata
name = "announcements"
@@ -45,7 +49,11 @@ class AnnouncementsCommand(BaseCommand):
self.announcements_acl = self._load_announcements_acl()
def _load_triggers(self) -> Dict[str, str]:
"""Load announcement triggers from config"""
"""Load announcement triggers from config.
Returns:
Dict[str, str]: Dictionary mapping trigger names to announcement text.
"""
triggers = {}
if self.bot.config.has_section('Announcements_Command'):
for key, value in self.bot.config.items('Announcements_Command'):
@@ -55,9 +63,12 @@ class AnnouncementsCommand(BaseCommand):
return triggers
def _load_announcements_acl(self) -> list:
"""
Load announcements ACL from config.
"""Load announcements ACL from config.
Inherits members of admin ACL if announcements_acl is not explicitly set.
Returns:
list: List of permitted public keys.
"""
acl_list = []
@@ -94,9 +105,15 @@ class AnnouncementsCommand(BaseCommand):
return acl_list
def _check_announcements_access(self, message: MeshMessage) -> bool:
"""
Check if the message sender has announcements access.
"""Check if the message sender has announcements access.
Uses the same security-hardened approach as admin ACL checking.
Args:
message: The message to check access for.
Returns:
bool: True if access is granted, False otherwise.
"""
if not hasattr(self.bot, 'config'):
return False
@@ -140,7 +157,14 @@ class AnnouncementsCommand(BaseCommand):
return has_access
def can_execute(self, message: MeshMessage) -> bool:
"""Check if announcements command can be executed"""
"""Check if announcements command can be executed.
Args:
message: The message trigger.
Returns:
bool: True if allowed to execute.
"""
# Check if command is enabled
if not self.enabled:
return False
@@ -156,7 +180,14 @@ class AnnouncementsCommand(BaseCommand):
return True
def _get_trigger_cooldown_remaining(self, trigger_name: str) -> int:
"""Get remaining cooldown time in minutes for a trigger"""
"""Get remaining cooldown time in minutes for a trigger.
Args:
trigger_name: Name of the announcement trigger.
Returns:
int: Remaining cooldown in minutes (0 if ready).
"""
if self.cooldown_seconds <= 0:
return 0
@@ -175,14 +206,25 @@ class AnnouncementsCommand(BaseCommand):
remaining_minutes = int((remaining_seconds + 59) // 60)
return remaining_minutes
def _record_trigger_execution(self, trigger_name: str):
"""Record the execution time for a trigger"""
def _record_trigger_execution(self, trigger_name: str) -> None:
"""Record the execution time for a trigger.
Args:
trigger_name: Name of the announcement trigger.
"""
current_time = time.time()
self.trigger_cooldowns[trigger_name] = current_time
self.trigger_lockouts[trigger_name] = current_time
def _is_trigger_locked(self, trigger_name: str) -> bool:
"""Check if a trigger is currently locked (within 60 seconds of last send)"""
"""Check if a trigger is currently locked (within 60 seconds of last send).
Args:
trigger_name: Name of the announcement trigger.
Returns:
bool: True if locked, False otherwise.
"""
if trigger_name not in self.trigger_lockouts:
return False
@@ -193,12 +235,15 @@ class AnnouncementsCommand(BaseCommand):
return elapsed < self.lockout_seconds
def _parse_command(self, content: str) -> tuple:
"""
Parse the announce command.
"""Parse the announce command.
Format: announce <trigger> [channel] [override]
Args:
content: Command content string.
Returns:
(trigger_name, channel_name, is_override) or (None, None, False) if invalid
tuple: (trigger_name, channel_name, is_override) or (None, None, False) if invalid.
"""
# Remove 'announce' keyword
parts = content.strip().split(None, 1)
@@ -224,7 +269,14 @@ class AnnouncementsCommand(BaseCommand):
return (trigger_name, channel_name, is_override)
async def execute(self, message: MeshMessage) -> bool:
"""Execute the announcements command"""
"""Execute the announcements command.
Args:
message: The input message trigger.
Returns:
bool: True if execution was successful.
"""
try:
# Parse command
trigger_name, channel_name, is_override = self._parse_command(message.content)
+93 -11
View File
@@ -16,7 +16,12 @@ from ..models import MeshMessage
class AqiCommand(BaseCommand):
"""Handles AQI commands with location support using OpenMeteo API"""
"""Handles AQI commands with location support using OpenMeteo API.
Provides Air Quality Index information for specified locations, including
cities, ZIP codes, and coordinates. Supports international locations and
provides health impact categories.
"""
# Plugin metadata
name = "aqi"
@@ -84,12 +89,23 @@ class AqiCommand(BaseCommand):
return f"Usage: aqi <city|neighborhood|city country|lat,lon|help> - Get AQI for city/neighborhood in {self.default_state}, international cities, coordinates, or pollutant help"
def get_pollutant_help(self) -> str:
"""Get help text explaining pollutant types within 130 characters"""
"""Get help text explaining pollutant types within 130 characters.
Returns:
str: Compact help string explaining pollutant abbreviations.
"""
# Compact explanation of all pollutants - fits within 130 chars
return "AQI Help: PM2.5=fine particles, PM10=coarse, O3=ozone, NO2=nitrogen dioxide, CO=carbon monoxide, SO2=sulfur dioxide"
async def execute(self, message: MeshMessage) -> bool:
"""Execute the AQI command"""
"""Execute the AQI command.
Args:
message: The input message trigger.
Returns:
bool: True if execution was successful.
"""
content = message.content.strip()
# Parse the command to extract location
@@ -275,7 +291,15 @@ class AqiCommand(BaseCommand):
return True
async def get_aqi_for_location(self, location: str, location_type: str) -> str:
"""Get AQI data for a location (city or coordinates)"""
"""Get AQI data for a location (city or coordinates).
Args:
location: Location string (city name, ZIP, or "lat,lon").
location_type: Type of location ("city", "zipcode", "coordinates").
Returns:
str: Formatted AQI string or error message.
"""
try:
# Define state abbreviation map for US states (needed for all location types)
state_abbrev_map = {
@@ -571,7 +595,14 @@ class AqiCommand(BaseCommand):
return f"Error getting AQI data: {e}"
def city_to_lat_lon(self, city: str) -> tuple:
"""Convert city name to latitude and longitude using default state"""
"""Convert city name to latitude and longitude using default state.
Args:
city: City name (can include state/country).
Returns:
tuple: (latitude, longitude, address_info) or (None, None, None).
"""
try:
# Check if the input contains a comma (city, state/country format)
if ',' in city:
@@ -652,7 +683,14 @@ class AqiCommand(BaseCommand):
return (None, None, None)
def get_neighborhood_queries(self, city: str) -> list:
"""Generate neighborhood-specific search queries for major cities"""
"""Generate neighborhood-specific search queries for major cities.
Args:
city: City name.
Returns:
list: List of neighborhood-specific query strings.
"""
city_lower = city.lower()
# Seattle neighborhoods
@@ -722,7 +760,15 @@ class AqiCommand(BaseCommand):
return []
def get_openmeteo_aqi(self, lat: float, lon: float) -> str:
"""Get AQI data from OpenMeteo API"""
"""Get AQI data from OpenMeteo API.
Args:
lat: Latitude.
lon: Longitude.
Returns:
str: Formatted AQI string or error constant.
"""
try:
# Make sure all required weather variables are listed here
# The order of variables in current is important to assign them correctly below
@@ -763,7 +809,22 @@ class AqiCommand(BaseCommand):
return self.ERROR_FETCHING_DATA
def format_aqi_response(self, us_aqi, european_aqi, pm10, pm2_5, co, no2, so2, ozone, dust) -> str:
"""Format AQI data for display within 130 characters"""
"""Format AQI data for display within 130 characters.
Args:
us_aqi: US Air Quality Index value.
european_aqi: European Air Quality Index value.
pm10: PM10 concentration.
pm2_5: PM2.5 concentration.
co: Carbon Monoxide concentration.
no2: Nitrogen Dioxide concentration.
so2: Sulfur Dioxide concentration.
ozone: Ozone concentration.
dust: Dust concentration.
Returns:
str: Formatted AQI string.
"""
try:
# Start with US AQI as primary
if us_aqi is not None and us_aqi > 0:
@@ -848,7 +909,14 @@ class AqiCommand(BaseCommand):
return "Error formatting AQI data"
def get_aqi_emoji(self, aqi: float) -> str:
"""Get emoji for US AQI value"""
"""Get emoji for US AQI value.
Args:
aqi: US AQI value.
Returns:
str: Emoji representing AQI level (🟢, 🟡, 🟠, 🔴, 🟣, 🟤).
"""
if aqi <= 50:
return "🟢" # Good
elif aqi <= 100:
@@ -863,7 +931,14 @@ class AqiCommand(BaseCommand):
return "🟤" # Hazardous
def get_european_aqi_emoji(self, aqi: float) -> str:
"""Get emoji for European AQI value"""
"""Get emoji for European AQI value.
Args:
aqi: European AQI value.
Returns:
str: Emoji representing European AQI level.
"""
if aqi <= 25:
return "🟢" # Good
elif aqi <= 50:
@@ -876,7 +951,14 @@ class AqiCommand(BaseCommand):
return "🟣" # Very Poor
def get_aqi_category(self, aqi: float) -> str:
"""Get category name for US AQI value"""
"""Get category name for US AQI value.
Args:
aqi: US AQI value.
Returns:
str: Category description (e.g., "Good", "Moderate").
"""
if aqi <= 50:
return "Good"
elif aqi <= 100:
+86 -49
View File
@@ -14,7 +14,12 @@ from ..security_utils import validate_pubkey_format
class BaseCommand(ABC):
"""Base class for all bot commands - Plugin Interface"""
"""Base class for all bot commands - Plugin Interface.
This class defines the interface that all commands must implement. It provides
common functionality for configuration loading, localization, permission checking,
rate limiting, and message response handling.
"""
# Plugin metadata - to be overridden by subclasses
name: str = ""
@@ -40,15 +45,14 @@ class BaseCommand(ABC):
self._load_translated_keywords()
def translate(self, key: str, **kwargs) -> str:
"""
Translate a key using the bot's translator
"""Translate a key using the bot's translator.
Args:
key: Dot-separated key path (e.g., 'commands.wx.usage')
**kwargs: Formatting parameters for string.format()
key: Dot-separated key path (e.g., 'commands.wx.usage').
**kwargs: Formatting parameters for string.format().
Returns:
Translated string, or key if translation not found
str: Translated string, or key if translation not found.
"""
if hasattr(self.bot, 'translator'):
return self.bot.translator.translate(key, **kwargs)
@@ -56,34 +60,32 @@ class BaseCommand(ABC):
return key
def translate_get_value(self, key: str) -> Any:
"""
Get a raw value from translations (can be string, list, dict, etc.)
"""Get a raw value from translations (can be string, list, dict, etc.).
Args:
key: Dot-separated key path (e.g., 'commands.hacker.sudo_errors')
key: Dot-separated key path (e.g., 'commands.hacker.sudo_errors').
Returns:
The value at the key path, or None if not found
Any: The value at the key path, or None if not found.
"""
if hasattr(self.bot, 'translator'):
return self.bot.translator.get_value(key)
return None
def get_config_value(self, section: str, key: str, fallback=None, value_type: str = 'str'):
"""
Get config value with backward compatibility for section name changes.
def get_config_value(self, section: str, key: str, fallback: Any = None, value_type: str = 'str') -> Any:
"""Get config value with backward compatibility for section name changes.
For command configs, checks both old format (e.g., 'Hacker') and new format (e.g., 'Hacker_Command').
This allows smooth migration from old config format to new standardized format.
Args:
section: Config section name (new format preferred)
key: Config key name
fallback: Default value if not found
value_type: Type of value ('str', 'bool', 'int', 'float')
section: Config section name (new format preferred).
key: Config key name.
fallback: Default value if not found.
value_type: Type of value ('str', 'bool', 'int', 'float', 'list').
Returns:
Config value of appropriate type, or fallback if not found
Any: Config value of appropriate type, or fallback if not found.
"""
# Map of old section names to new standardized names
section_migration = {
@@ -145,21 +147,35 @@ class BaseCommand(ABC):
return fallback
@abstractmethod
@abstractmethod
async def execute(self, message: MeshMessage) -> bool:
"""Execute the command with the given message"""
"""Execute the command with the given message.
Args:
message: The message that triggered the command.
Returns:
bool: True if execution was successful, False otherwise.
"""
pass
def get_help_text(self) -> str:
"""Get help text for this command"""
"""Get help text for this command.
Returns:
str: The help text (description) for this command.
"""
return self.description or "No help available for this command."
def _derive_config_section_name(self) -> str:
"""
Derive config section name from command name.
"""Derive config section name from command name.
Handles camelCase names like "dadjoke" -> "DadJoke_Command"
Regular names like "sports" -> "Sports_Command"
Returns:
str: The derived config section name.
"""
# Special handling for camelCase names
camel_case_map = {
@@ -175,16 +191,16 @@ class BaseCommand(ABC):
return f"{base_name}_Command"
def _load_allowed_channels(self) -> Optional[List[str]]:
"""
Load allowed channels from config.
"""Load allowed channels from config.
Config format: [CommandName_Command]
channels = channel1,channel2,channel3
Returns:
- None: Use global monitor_channels (default behavior)
- Empty list []: Command disabled for all channels (only DMs)
- List of channels: Command only works in these channels
Optional[List[str]]:
- None: Use global monitor_channels (default behavior)
- Empty list []: Command disabled for all channels (only DMs)
- List of channels: Command only works in these channels
"""
# Derive section name from command name
# Convert "sports" -> "Sports_Command", "greeter" -> "Greeter_Command", etc.
@@ -205,13 +221,16 @@ class BaseCommand(ABC):
return channels if channels else None
def is_channel_allowed(self, message: MeshMessage) -> bool:
"""
Check if this command is allowed in the message's channel.
"""Check if this command is allowed in the message's channel.
Args:
message: The message to check.
Returns:
- True if DM and command allows DMs (unless requires_dm is False, but that's separate)
- True if channel is in allowed_channels (or None for global)
- False otherwise
bool:
- True if DM and command allows DMs (unless requires_dm is False, but that's separate)
- True if channel is in allowed_channels (or None for global)
- False otherwise
"""
# DMs are always allowed (unless requires_dm is False, but that's checked separately)
if message.is_dm:
@@ -229,7 +248,16 @@ class BaseCommand(ABC):
return message.channel in self.allowed_channels
def can_execute(self, message: MeshMessage) -> bool:
"""Check if this command can be executed with the given message"""
"""Check if this command can be executed with the given message.
Checks channel permissions, DM requirements, cooldowns, and admin access.
Args:
message: The message to check execution for.
Returns:
bool: True if the command can be executed, False otherwise.
"""
# Check channel access (standardized channel override)
if not self.is_channel_allowed(message):
return False
@@ -252,7 +280,11 @@ class BaseCommand(ABC):
return True
def get_metadata(self) -> Dict[str, Any]:
"""Get plugin metadata for discovery and registration"""
"""Get plugin metadata for discovery and registration.
Returns:
Dict[str, Any]: A dictionary containing metadata about the command.
"""
return {
'name': self.name,
'keywords': self.keywords,
@@ -266,7 +298,15 @@ class BaseCommand(ABC):
}
async def send_response(self, message: MeshMessage, content: str) -> bool:
"""Unified method for sending responses to users"""
"""Unified method for sending responses to users.
Args:
message: The message to respond to.
content: The response content.
Returns:
bool: True if the response was sent successfully, False otherwise.
"""
try:
# Use the command manager's send_response method to ensure response capture
return await self.bot.command_manager.send_response(message, content)
@@ -275,8 +315,7 @@ class BaseCommand(ABC):
return False
def get_max_message_length(self, message: MeshMessage) -> int:
"""
Calculate the maximum message length dynamically based on message type and bot username.
"""Calculate the maximum message length dynamically based on message type and bot username.
Channel messages are formatted as "<username>: <message>", so:
max_length = 150 - username_length - 2 (for ": ")
@@ -285,10 +324,10 @@ class BaseCommand(ABC):
max_length = 150
Args:
message: The MeshMessage to calculate max length for
message: The MeshMessage to calculate max length for.
Returns:
Maximum message length in characters
int: Maximum message length in characters.
"""
# For DMs, no username prefix - full 150 characters available
if message.is_dm:
@@ -323,14 +362,15 @@ class BaseCommand(ABC):
return max(130, max_length)
def check_cooldown(self, user_id: Optional[str] = None) -> Tuple[bool, float]:
"""
Check if user is on cooldown.
"""Check if user is on cooldown.
Args:
user_id: User ID to check cooldown for. If None, checks global cooldown.
Returns:
Tuple of (can_execute: bool, remaining_seconds: float)
Tuple[bool, float]: A tuple containing:
- can_execute: True if command can be executed, False otherwise.
- remaining_seconds: Float representing seconds remaining on cooldown.
"""
if self.cooldown_seconds <= 0:
return True, 0.0
@@ -356,8 +396,7 @@ class BaseCommand(ABC):
return True, 0.0
def record_execution(self, user_id: Optional[str] = None) -> None:
"""
Record command execution for cooldown tracking.
"""Record command execution for cooldown tracking.
Args:
user_id: User ID to record execution for. If None, records global execution.
@@ -381,8 +420,7 @@ class BaseCommand(ABC):
self._last_execution_time = current_time
def _record_execution(self, user_id: Optional[str] = None):
"""
Record the execution time for cooldown tracking (backward compatibility).
"""Record the execution time for cooldown tracking (backward compatibility).
Args:
user_id: User ID to record execution for. If None, records global execution.
@@ -390,14 +428,13 @@ class BaseCommand(ABC):
self.record_execution(user_id)
def get_remaining_cooldown(self, user_id: Optional[str] = None) -> int:
"""
Get remaining cooldown time in seconds.
"""Get remaining cooldown time in seconds.
Args:
user_id: User ID to check cooldown for. If None, checks global cooldown.
Returns:
Remaining cooldown time in seconds (as integer)
int: Remaining cooldown time in seconds (as integer).
"""
_, remaining = self.check_cooldown(user_id)
return max(0, int(remaining))
+32 -4
View File
@@ -5,12 +5,17 @@ Provides random cat facts as a hidden easter egg command
"""
import random
from typing import List
from .base_command import BaseCommand
from ..models import MeshMessage
class CatfactCommand(BaseCommand):
"""Handles cat fact commands - hidden easter egg"""
"""Handles cat fact commands - hidden easter egg.
Responds to various cat-related keywords with random facts about cats.
This is designed as a hidden feature and does not appear in standard help listings.
"""
# Plugin metadata
name = "catfact"
@@ -20,6 +25,11 @@ class CatfactCommand(BaseCommand):
cooldown_seconds = 3 # 3 second cooldown per user
def __init__(self, bot):
"""Initialize the catfact command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
# Collection of cat facts - fallback if translations not available
@@ -94,19 +104,37 @@ class CatfactCommand(BaseCommand):
"A cat's average body temperature is 101.5°F (38.6°C) - higher than humans. 🌡️"
]
def get_cat_facts(self) -> list:
"""Get cat facts from translations or fallback to hardcoded list"""
def get_cat_facts(self) -> List[str]:
"""Get cat facts from translations or fallback to hardcoded list.
Returns:
List[str]: A list of cat fact strings.
"""
facts = self.translate_get_value('commands.catfact.facts')
if facts and isinstance(facts, list) and len(facts) > 0:
return facts
return self.cat_facts_fallback
def get_help_text(self) -> str:
"""Get help text for the catfact command.
Returns:
str: Empty string (to keep the command hidden).
"""
# Return empty string so it doesn't appear in help
return ""
async def execute(self, message: MeshMessage) -> bool:
"""Execute the cat fact command"""
"""Execute the cat fact command.
Selects a random cat fact and sends it to the user.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
try:
# Record execution for this user
self.record_execution(message.sender_id)
+102 -18
View File
@@ -6,12 +6,17 @@ Lists common hashtag channels for the region with multi-message support
from .base_command import BaseCommand
from ..models import MeshMessage
from typing import Optional
import asyncio
import re
class ChannelsCommand(BaseCommand):
"""Handles the channels command"""
"""Handles the channels command.
Lists common hashtag channels for the region with multi-message support
and sub-category filtering.
"""
# Plugin metadata
name = "channels"
@@ -23,7 +28,14 @@ class ChannelsCommand(BaseCommand):
return self.translate('commands.channels.help')
def matches_keyword(self, message: MeshMessage) -> bool:
"""Check if this command matches the message content based on keywords"""
"""Check if this command matches the message content based on keywords.
Args:
message: The message to check.
Returns:
bool: True if the message matches a command keyword.
"""
if not self.keywords:
return False
@@ -60,7 +72,14 @@ class ChannelsCommand(BaseCommand):
return False
async def execute(self, message: MeshMessage) -> bool:
"""Execute the channels command"""
"""Execute the channels command.
Args:
message: The input message trigger.
Returns:
bool: True if execution was successful.
"""
try:
# Parse the command to check for sub-commands
content = message.content.strip()
@@ -131,7 +150,14 @@ class ChannelsCommand(BaseCommand):
return False
def _load_channels_from_config(self, sub_command: str = None) -> dict:
"""Load channels from the Channels_List config section with optional sub-command filtering"""
"""Load channels from the Channels_List config section with optional sub-command filtering.
Args:
sub_command: Optional category filter.
Returns:
dict: Dictionary of channel names to descriptions.
"""
channels = {}
for channel_name, description in self._parse_config_channels():
@@ -163,8 +189,12 @@ class ChannelsCommand(BaseCommand):
return channels
async def _show_all_categories(self, message: MeshMessage):
"""Show all available channel categories"""
async def _show_all_categories(self, message: MeshMessage) -> None:
"""Show all available channel categories.
Args:
message: The message to reply to.
"""
try:
categories = self._get_all_categories()
@@ -188,7 +218,11 @@ class ChannelsCommand(BaseCommand):
await self.send_response(message, self.translate('commands.channels.error_retrieving_categories', error=str(e)))
def _get_all_categories(self) -> dict:
"""Get all available channel categories and their channel counts"""
"""Get all available channel categories and their channel counts.
Returns:
dict: Dictionary mapping category names to channel counts.
"""
categories = {}
for channel_name, description in self._parse_config_channels():
@@ -206,8 +240,15 @@ class ChannelsCommand(BaseCommand):
return categories
def _find_channel_by_name(self, search_name: str) -> str:
"""Find a channel by partial name match across all categories"""
def _find_channel_by_name(self, search_name: str) -> Optional[str]:
"""Find a channel by partial name match across all categories.
Args:
search_name: The channel name to search for.
Returns:
Optional[str]: The full channel name if found, None otherwise.
"""
search_name_lower = search_name.lower()
for config_name, description in self._parse_config_channels():
@@ -224,8 +265,13 @@ class ChannelsCommand(BaseCommand):
return None
async def _show_specific_channel(self, message: MeshMessage, channel_name: str):
"""Show description for a specific channel"""
async def _show_specific_channel(self, message: MeshMessage, channel_name: str) -> None:
"""Show description for a specific channel.
Args:
message: The message to reply to.
channel_name: The channel name to show info for.
"""
try:
# Search for the channel in all categories
found_channel = None
@@ -268,7 +314,15 @@ class ChannelsCommand(BaseCommand):
await self.send_response(message, self.translate('commands.channels.error_retrieving_channel_info', error=str(e)))
def _split_into_messages(self, channel_list: list, sub_command: str = None) -> list:
"""Split channel list into multiple messages if they exceed 130 characters"""
"""Split channel list into multiple messages if they exceed 130 characters.
Args:
channel_list: List of channel string items.
sub_command: The current sub-command/category context.
Returns:
list: List of message strings ready for sending.
"""
messages = []
# Set appropriate header based on sub-command
@@ -318,7 +372,14 @@ class ChannelsCommand(BaseCommand):
return messages
def _get_header_for_subcommand(self, sub_command: str = None) -> str:
"""Get the appropriate header for a sub-command"""
"""Get the appropriate header for a sub-command.
Args:
sub_command: The sub-command/category name.
Returns:
str: Header string.
"""
if sub_command == "Available categories":
return self.translate('commands.channels.headers.available_categories')
elif sub_command and sub_command != "general":
@@ -327,7 +388,14 @@ class ChannelsCommand(BaseCommand):
return self.translate('commands.channels.headers.common_channels')
def _get_continuation_header_for_subcommand(self, sub_command: str = None) -> str:
"""Get the appropriate header for continuation messages"""
"""Get the appropriate header for continuation messages.
Args:
sub_command: The sub-command/category name.
Returns:
str: Continuation header string.
"""
if sub_command == "Available categories":
return self.translate('commands.channels.headers.available_categories')
elif sub_command and sub_command != "general":
@@ -335,8 +403,13 @@ class ChannelsCommand(BaseCommand):
else:
return self.translate('commands.channels.headers.common_channels_cont')
async def _send_multiple_messages(self, message: MeshMessage, messages: list):
"""Send multiple messages with delays between them"""
async def _send_multiple_messages(self, message: MeshMessage, messages: list) -> None:
"""Send multiple messages with delays between them.
Args:
message: The original command message.
messages: List of message strings to send.
"""
for i, msg_content in enumerate(messages):
if i > 0:
# Small delay between messages to prevent overwhelming the network
@@ -344,7 +417,11 @@ class ChannelsCommand(BaseCommand):
await self.send_response(message, msg_content)
def _parse_config_channels(self):
"""Parse all channels from config, returning a generator of (name, description) tuples"""
"""Parse all channels from config, returning a generator of (name, description) tuples.
Yields:
tuple: (channel_name, description) pairs.
"""
if not self.bot.config.has_section('Channels_List'):
return
@@ -357,7 +434,14 @@ class ChannelsCommand(BaseCommand):
yield channel_name, description
def _is_valid_category(self, category_name: str) -> bool:
"""Check if a category name is valid (has channels with that prefix)"""
"""Check if a category name is valid (has channels with that prefix).
Args:
category_name: The category to check.
Returns:
bool: True if the category exists.
"""
if not category_name:
return False
+22 -9
View File
@@ -4,6 +4,7 @@ Cmd command for the MeshCore Bot
Lists available commands in a compact, comma-separated format for LoRa
"""
from typing import Optional
from .base_command import BaseCommand
from ..models import MeshMessage
@@ -18,16 +19,21 @@ class CmdCommand(BaseCommand):
category = "basic"
def get_help_text(self) -> str:
return "Lists commands in compact format."
def _get_commands_list(self, max_length: int = None) -> str:
"""Get a compact list of available commands, prioritizing important ones
Args:
max_length: Maximum length for the command list (None = no limit)
"""Get help text for the cmd command.
Returns:
Comma-separated list of commands, truncated if necessary
str: The help text for this command.
"""
return "Lists commands in compact format."
def _get_commands_list(self, max_length: Optional[int] = None) -> str:
"""Get a compact list of available commands, prioritizing important ones.
Args:
max_length: Maximum length for the command list (None = no limit).
Returns:
str: Comma-separated list of commands, truncated if necessary.
"""
# Define priority order - most important/commonly used commands first
priority_commands = [
@@ -109,7 +115,14 @@ class CmdCommand(BaseCommand):
return prefix + ', '.join(result)
async def execute(self, message: MeshMessage) -> bool:
"""Execute the cmd command"""
"""Execute the cmd command.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
try:
# Check if user has defined a custom cmd keyword response in config
# Use the already-loaded keywords dict (quotes are already stripped)
+67 -9
View File
@@ -29,6 +29,11 @@ class DadJokeCommand(BaseCommand):
TIMEOUT = 10 # seconds
def __init__(self, bot):
"""Initialize the dadjoke command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
# Load configuration
@@ -36,10 +41,22 @@ class DadJokeCommand(BaseCommand):
self.long_jokes = bot.config.getboolean('Jokes', 'long_jokes', fallback=False)
def get_help_text(self) -> str:
"""Get help text for the dadjoke command.
Returns:
str: The help text for this command.
"""
return "Usage: dadjoke - Get a random dad joke"
def matches_keyword(self, message: MeshMessage) -> bool:
"""Check if message starts with a dad joke keyword"""
"""Check if message starts with a dad joke keyword.
Args:
message: The received message.
Returns:
bool: True if message matches a keyword, False otherwise.
"""
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
@@ -51,7 +68,14 @@ class DadJokeCommand(BaseCommand):
return False
def can_execute(self, message: MeshMessage) -> bool:
"""Override to add custom check (dadjoke_enabled) while using base class cooldown"""
"""Override to add custom check (dadjoke_enabled) while using base class cooldown.
Args:
message: The message triggering the command.
Returns:
bool: True if the command can be executed, False otherwise.
"""
# Use base class for channel access, DM requirements, and cooldown
if not super().can_execute(message):
return False
@@ -63,7 +87,14 @@ class DadJokeCommand(BaseCommand):
return True
async def execute(self, message: MeshMessage) -> bool:
"""Execute the dad joke command"""
"""Execute the dad joke command.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
try:
# Record execution for this user
self.record_execution(message.sender_id)
@@ -86,7 +117,11 @@ class DadJokeCommand(BaseCommand):
return True
async def get_dad_joke_from_api(self) -> Optional[Dict[str, Any]]:
"""Get a dad joke from icanhazdadjoke.com API"""
"""Get a dad joke from icanhazdadjoke.com API.
Returns:
Optional[Dict[str, Any]]: The JSON response from the API, or None if failed.
"""
try:
headers = {
'Accept': 'application/json',
@@ -128,7 +163,11 @@ class DadJokeCommand(BaseCommand):
return None
async def get_dad_joke_with_length_handling(self) -> Optional[Dict[str, Any]]:
"""Get a dad joke from API with length handling based on configuration"""
"""Get a dad joke from API with length handling based on configuration.
Returns:
Optional[Dict[str, Any]]: The JSON response from the API, or None if failed.
"""
max_attempts = 5 # Prevent infinite loops
for attempt in range(max_attempts):
@@ -155,8 +194,13 @@ class DadJokeCommand(BaseCommand):
self.logger.warning(f"Could not get short dad joke after {max_attempts} attempts")
return joke_data
async def send_dad_joke_with_length_handling(self, message: MeshMessage, joke_data: Dict[str, Any]):
"""Send dad joke with length handling - split if necessary"""
async def send_dad_joke_with_length_handling(self, message: MeshMessage, joke_data: Dict[str, Any]) -> None:
"""Send dad joke with length handling - split if necessary.
Args:
message: The message to reply to.
joke_data: The joke data from the API.
"""
joke_text = self.format_dad_joke(joke_data)
if len(joke_text) <= 130:
@@ -177,7 +221,14 @@ class DadJokeCommand(BaseCommand):
await self.send_response(message, joke_text)
def split_dad_joke(self, joke_text: str) -> list:
"""Split a long dad joke at a logical point"""
"""Split a long dad joke at a logical point.
Args:
joke_text: The long joke text to split.
Returns:
list: A list of two strings (the split parts).
"""
# Remove emoji for splitting
clean_joke = joke_text[2:] if joke_text.startswith('🥸 ') else joke_text
@@ -210,7 +261,14 @@ class DadJokeCommand(BaseCommand):
return [f"🥸 {part1}", f"🥸 {part2}"]
def format_dad_joke(self, joke_data: Dict[str, Any]) -> str:
"""Format the dad joke data into a readable string"""
"""Format the dad joke data into a readable string.
Args:
joke_data: The joke data from the API.
Returns:
str: The formatted joke string.
"""
try:
joke = joke_data.get('joke', '')
+65 -14
View File
@@ -30,10 +30,22 @@ class DiceCommand(BaseCommand):
}
def get_help_text(self) -> str:
"""Get help text for the dice command.
Returns:
str: Help text string.
"""
return self.translate('commands.dice.help')
def matches_keyword(self, message: MeshMessage) -> bool:
"""Override to handle dice-specific matching"""
"""Override to handle dice-specific matching.
Args:
message: The received message.
Returns:
bool: True if message is a dice command, False otherwise.
"""
content = message.content.strip().lower()
# Handle command-style messages
@@ -54,10 +66,15 @@ class DiceCommand(BaseCommand):
return False
def parse_dice_notation(self, dice_input: str) -> tuple:
"""
Parse dice notation and return (sides, count, is_decade)
"""Parse dice notation and return (sides, count, is_decade).
Supports: d20, 20, d6, 6, 2d6, 4d10, decade, etc.
Returns (sides, count, is_decade) or (None, None, False) if invalid
Args:
dice_input: The dice string to parse.
Returns:
tuple: (sides, count, is_decade) or (None, None, False) if invalid.
"""
dice_input = dice_input.strip().lower()
@@ -107,10 +124,15 @@ class DiceCommand(BaseCommand):
return None, None, False
def parse_mixed_dice(self, dice_input: str) -> list:
"""
Parse mixed dice notation and return list of (sides, count, is_decade) tuples
"""Parse mixed dice notation and return list of (sides, count, is_decade) tuples.
Supports: "d10 d6", "2d6 d20", "d4 d8 d12", "decade", etc.
Returns list of (sides, count, is_decade) tuples, or empty list if invalid
Args:
dice_input: The space-separated dice string.
Returns:
list: List of (sides, count, is_decade) tuples, or empty list if invalid.
"""
dice_input = dice_input.strip()
if not dice_input:
@@ -129,9 +151,17 @@ class DiceCommand(BaseCommand):
return parsed_dice
def roll_dice(self, sides: int, count: int = 1, is_decade: bool = False) -> list:
"""
Roll dice and return list of results
"""Roll dice and return list of results.
For decade dice, returns values 0, 10, 20, ..., 90 (formatted as 00, 10, 20, etc.)
Args:
sides: Number of sides on the die.
count: Number of dice to roll.
is_decade: Whether it's a decade die (00-90).
Returns:
list: List of integer results.
"""
if is_decade:
# Decade die: 00, 10, 20, 30, 40, 50, 60, 70, 80, 90
@@ -140,7 +170,17 @@ class DiceCommand(BaseCommand):
return [random.randint(1, sides) for _ in range(count)]
def format_dice_result(self, sides: int, count: int, results: list, is_decade: bool = False) -> str:
"""Format dice roll results into a readable string"""
"""Format dice roll results into a readable string.
Args:
sides: Number of sides.
count: Number of dice.
results: List of roll results.
is_decade: Whether it's a decade die.
Returns:
str: Formatted result string.
"""
if is_decade:
# Format decade dice results (00, 10, 20, etc.)
formatted_results = [f"{r:02d}" for r in results]
@@ -161,9 +201,13 @@ class DiceCommand(BaseCommand):
return self.translate('commands.dice.multiple_dice', count=count, sides=sides, results=results_str, total=total)
def format_mixed_dice_result(self, dice_results: list) -> str:
"""
Format mixed dice roll results into a readable string
dice_results: list of tuples (sides, count, results_list, is_decade)
"""Format mixed dice roll results into a readable string.
Args:
dice_results: list of tuples (sides, count, results_list, is_decade).
Returns:
str: Formatted result string for all dice.
"""
parts = []
grand_total = 0
@@ -194,7 +238,14 @@ class DiceCommand(BaseCommand):
return f"🎲 {result_str}"
async def execute(self, message: MeshMessage) -> bool:
"""Execute the dice command"""
"""Execute the dice command.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
content = message.content.strip()
# Handle command-style messages
+49 -5
View File
@@ -11,7 +11,12 @@ from ..models import MeshMessage
class HelpCommand(BaseCommand):
"""Handles the help command"""
"""Handles the help command.
Provides assistance to users by listing available commands or displaying
detailed help for specific commands. It dynamically aggregates command
information from all loaded plugins.
"""
# Plugin metadata
name = "help"
@@ -20,17 +25,43 @@ class HelpCommand(BaseCommand):
category = "basic"
def get_help_text(self) -> str:
"""Get help text for the help command.
Returns:
str: The help text for this command.
"""
return self.translate('commands.help.description')
async def execute(self, message: MeshMessage) -> bool:
"""Execute the help command"""
"""Execute the help command.
Note: The help command logic is primarily handled by the CommandManager's
keyword matching system. This method serves as a placeholder or fallback.
Args:
message: The message that triggered the command.
Returns:
bool: True (always, as actual processing happens elsewhere).
"""
# The help command is now handled by keyword matching in the command manager
# This is just a placeholder for future functionality
self.logger.debug("Help command executed (handled by keyword matching)")
return True
def get_specific_help(self, command_name: str, message: MeshMessage = None) -> str:
"""Get help text for a specific command"""
"""Get help text for a specific command.
Resolves aliases, finds the corresponding command plugin, and retrieves
its help text.
Args:
command_name: The name or alias of the command.
message: Optional message object for context-aware help.
Returns:
str: The formatted help text for the specific command.
"""
# Map command aliases to their actual command names
command_aliases = {
't': 't_phrase',
@@ -62,7 +93,13 @@ class HelpCommand(BaseCommand):
return self.translate('commands.help.unknown', command=command_name, available=available)
def get_general_help(self) -> str:
"""Get general help text"""
"""Get general help text.
Compiles a list of available commands and usage examples.
Returns:
str: The general help message to display to users.
"""
commands_list = self.get_available_commands_list()
help_text = self.translate('commands.help.general', commands_list=commands_list)
help_text += self.translate('commands.help.usage_examples')
@@ -70,7 +107,14 @@ class HelpCommand(BaseCommand):
return help_text
def get_available_commands_list(self) -> str:
"""Get a list of most popular commands in descending order, showing only one variant per command"""
"""Get a list of most popular commands in descending order.
Queries usage statistics to order commands by popularity. Ensures each
command is listed only once using its primary name.
Returns:
str: Comma-separated list of command names.
"""
try:
# Use the plugin loader's keyword mappings to map keywords/aliases to primary command names
plugin_loader = self.bot.command_manager.plugin_loader
+24 -4
View File
@@ -9,7 +9,11 @@ from ..models import MeshMessage
class HfcondCommand(BaseCommand):
"""Command to get HF band conditions"""
"""Command to get HF band conditions.
Retrieves and displays propagation conditions for High Frequency (HF) bands,
useful for amateur radio operators.
"""
# Plugin metadata
name = "hfcond"
@@ -18,10 +22,22 @@ class HfcondCommand(BaseCommand):
category = "solar"
def __init__(self, bot):
"""Initialize the hfcond command.
Args:
bot: The MeshCoreBot instance.
"""
super().__init__(bot)
async def execute(self, message: MeshMessage) -> bool:
"""Execute the hfcond command"""
"""Execute the hfcond command.
Args:
message: The message that triggered the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
try:
# Get HF band conditions
hf_info = hf_band_conditions()
@@ -34,6 +50,10 @@ class HfcondCommand(BaseCommand):
error_msg = self.translate('commands.hfcond.error', error=str(e))
return await self.send_response(message, error_msg)
def get_help_text(self):
"""Get help text for this command"""
def get_help_text(self) -> str:
"""Get help text for this command.
Returns:
str: The help text for this command.
"""
return self.translate('commands.hfcond.help')
+26 -4
View File
@@ -4,6 +4,7 @@ Magic 8-ball command for the MeshCore Bot
Handles the 'magic8' keyword response
"""
import random
from typing import Optional
from .base_command import BaseCommand
from ..models import MeshMessage
@@ -15,7 +16,10 @@ def magic8():
class Magic8Command(BaseCommand):
"""Handles the magic8 command"""
"""Handles the magic8 command.
Emulates a Magic 8-Ball, providing a random "fortune" response to a user's question.
"""
# Plugin metadata
name = "magic8"
@@ -24,17 +28,35 @@ class Magic8Command(BaseCommand):
category = "games"
def get_help_text(self) -> str:
"""Get help text for the magic8 command.
Returns:
str: The help text for this command.
"""
return self.translate('commands.magic8.description')
def get_response_format(self) -> str:
"""Get the response format from config"""
def get_response_format(self) -> Optional[str]:
"""Get the response format from config.
Returns:
Optional[str]: The format string for the response, or None if not configured.
"""
if self.bot.config.has_section('Keywords'):
format_str = self.bot.config.get('Keywords', 'magic8', fallback=None)
return self._strip_quotes_from_config(format_str) if format_str else None
return None
async def execute(self, message: MeshMessage) -> bool:
"""Execute the magic8 command"""
"""Execute the magic8 command.
Selects a random response and sends it to the user.
Args:
message: The message that triggered the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
answer = magic8()
# Format response with sender mention for channel messages, without for DMs
+35 -5
View File
@@ -18,10 +18,22 @@ class MoonCommand(BaseCommand):
category = "solar"
def __init__(self, bot):
"""Initialize the moon command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
async def execute(self, message: MeshMessage) -> bool:
"""Execute the moon command"""
"""Execute the moon command.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
try:
# Get moon information using default location
moon_info = get_moon()
@@ -39,7 +51,14 @@ class MoonCommand(BaseCommand):
return False
def _translate_phase_name(self, phase_name: str) -> str:
"""Translate English phase name to localized version"""
"""Translate English phase name to localized version.
Args:
phase_name: The English phase name (e.g., 'New Moon').
Returns:
str: The translated phase name, or original if not found.
"""
# Map English phase names (with or without emoji) to translation keys
phase_mapping = {
'New Moon': 'new_moon',
@@ -69,7 +88,14 @@ class MoonCommand(BaseCommand):
return phase_name
def _format_moon_response(self, moon_info: str) -> str:
"""Format moon information to be more compact and readable"""
"""Format moon information to be more compact and readable.
Args:
moon_info: The raw moon info string.
Returns:
str: The formatted response string.
"""
try:
# Parse the moon info string to extract key information
lines = moon_info.split('\n')
@@ -130,6 +156,10 @@ class MoonCommand(BaseCommand):
# Fallback to original format if formatting fails
return self.translate('commands.moon.fallback', info=moon_info)
def get_help_text(self):
"""Get help text for this command"""
def get_help_text(self) -> str:
"""Get help text for this command.
Returns:
str: The help text for this command.
"""
return self.description
+25 -4
View File
@@ -4,12 +4,17 @@ Ping command for the MeshCore Bot
Handles the 'ping' keyword response
"""
from typing import Optional
from .base_command import BaseCommand
from ..models import MeshMessage
class PingCommand(BaseCommand):
"""Handles the ping command"""
"""Handles the ping command.
A simple diagnostic command that responds with 'Pong!' or a custom configured response
to verify bot connectivity and responsiveness.
"""
# Plugin metadata
name = "ping"
@@ -18,15 +23,31 @@ class PingCommand(BaseCommand):
category = "basic"
def get_help_text(self) -> str:
"""Get help text for the ping command.
Returns:
str: The help text for this command.
"""
return self.translate('commands.ping.description')
def get_response_format(self) -> str:
"""Get the response format from config"""
def get_response_format(self) -> Optional[str]:
"""Get the response format from config.
Returns:
Optional[str]: The format string for the response, or None if not configured.
"""
if self.bot.config.has_section('Keywords'):
format_str = self.bot.config.get('Keywords', 'ping', fallback=None)
return self._strip_quotes_from_config(format_str) if format_str else None
return None
async def execute(self, message: MeshMessage) -> bool:
"""Execute the ping command"""
"""Execute the ping command.
Args:
message: The message that triggered the command.
Returns:
bool: True if the response was sent successfully, False otherwise.
"""
return await self.handle_keyword_match(message)
+124 -18
View File
@@ -11,7 +11,11 @@ from typing import List, Optional
class RepeaterCommand(BaseCommand):
"""Command for managing repeater contacts"""
"""Command for managing repeater contacts.
Provides functionality to scan, list, purge, and manage repeater and companion contacts
within the mesh network. Includes automated cleanup tools and statistics.
"""
# Plugin metadata
name = "repeater"
@@ -25,7 +29,14 @@ class RepeaterCommand(BaseCommand):
super().__init__(bot)
def matches_keyword(self, message: MeshMessage) -> bool:
"""Check if message starts with 'repeater' keyword"""
"""Check if message starts with 'repeater' keyword.
Args:
message: The message to check for the keyword.
Returns:
bool: True if the message starts with any of the command keywords.
"""
content = message.content.strip()
# Handle exclamation prefix
@@ -40,7 +51,16 @@ class RepeaterCommand(BaseCommand):
return False
async def execute(self, message: MeshMessage) -> bool:
"""Execute repeater management command"""
"""Execute repeater management command.
Parses subcommands (scan, list, purge, etc.) and routes to the appropriate handler.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
self.logger.info(f"Repeater command executed with content: {message.content}")
# Parse the message content to extract subcommand and args
@@ -129,7 +149,13 @@ class RepeaterCommand(BaseCommand):
return True
async def _handle_scan(self) -> str:
"""Scan contacts for repeaters"""
"""Scan contacts for repeaters.
Triggers a scan of the device's contact list to identify and catalog repeaters.
Returns:
str: Result message describing the scan outcome.
"""
self.logger.info("Repeater scan command received")
if not hasattr(self.bot, 'repeater_manager'):
@@ -157,7 +183,14 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error scanning for repeaters: {e}"
async def _handle_list(self, args: List[str]) -> str:
"""List repeater contacts"""
"""List repeater contacts.
Args:
args: Command arguments (e.g., '--all' to show purged repeaters).
Returns:
str: Formatted list of repeaters.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -205,7 +238,16 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error listing repeaters: {e}"
async def _handle_purge(self, args: List[str]) -> str:
"""Purge repeater or companion contacts"""
"""Purge repeater or companion contacts.
Supports purging by name, age (days), or 'all'.
Args:
args: Command arguments specifying what to purge.
Returns:
str: Result message describing the purge outcome.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -363,7 +405,14 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error purging repeaters: {e}"
async def _handle_purge_companions(self, args: List[str]) -> str:
"""Purge companion contacts based on inactivity"""
"""Purge companion contacts based on inactivity.
Args:
args: Command arguments (optional days threshold).
Returns:
str: Result message describing the purge outcome.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -448,7 +497,14 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error purging companions: {e}"
async def _handle_restore(self, args: List[str]) -> str:
"""Restore purged repeater contacts"""
"""Restore purged repeater contacts.
Args:
args: Command arguments (name pattern to restore).
Returns:
str: Result message describing the restore outcome.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -489,7 +545,11 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error restoring repeaters: {e}"
async def _handle_stats(self) -> str:
"""Show repeater management statistics"""
"""Show repeater management statistics.
Returns:
str: Formatted statistics summary.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -517,7 +577,11 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error getting statistics: {e}"
async def _handle_status(self) -> str:
"""Show contact list status and limits"""
"""Show contact list status and limits.
Returns:
str: Formatted status message showing usage vs limits.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -546,7 +610,14 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error getting contact status: {e}"
async def _handle_manage(self, args: List[str]) -> str:
"""Manage contact list to prevent hitting limits"""
"""Manage contact list to prevent hitting limits.
Args:
args: Command arguments (e.g., '--dry-run').
Returns:
str: Result message describing actions taken or proposed.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -609,7 +680,14 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error managing contact list: {e}"
async def _handle_add(self, args: List[str]) -> str:
"""Add a discovered contact to the contact list"""
"""Add a discovered contact to the contact list.
Args:
args: Command arguments (name, public_key, reason).
Returns:
str: Result message indicating success or failure.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -634,7 +712,13 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error adding contact: {e}"
async def _handle_discover(self) -> str:
"""Discover companion contacts"""
"""Discover companion contacts.
Triggers manual discovery of companion contacts.
Returns:
str: Result message.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -649,8 +733,12 @@ class RepeaterCommand(BaseCommand):
except Exception as e:
return f"❌ Error discovering contacts: {e}"
async def _handle_stats(self) -> str:
"""Show statistics about the complete repeater tracking database"""
async def _handle_contact_stats(self) -> str:
"""Show statistics about the complete repeater tracking database.
Returns:
str: Formatted statistics summary.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -691,7 +779,14 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error getting repeater statistics: {e}"
async def _handle_auto_purge(self, args: List[str]) -> str:
"""Handle auto-purge commands"""
"""Handle auto-purge commands.
Args:
args: Command arguments (trigger, enable, disable, monitor).
Returns:
str: Result message.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -744,7 +839,11 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error with auto-purge command: {e}"
async def _handle_purge_status(self) -> str:
"""Show detailed purge status and recommendations"""
"""Show detailed purge status and recommendations.
Returns:
str: Formatted status message.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
@@ -771,7 +870,14 @@ class RepeaterCommand(BaseCommand):
return f"❌ Error getting purge status: {e}"
async def _handle_test_purge(self) -> str:
"""Test the improved purge system"""
"""Test the improved purge system.
Runs a test purge operation without permanently removing valid contacts,
useful for verifying system functionality.
Returns:
str: Test result message.
"""
if not hasattr(self.bot, 'repeater_manager'):
return "Repeater manager not initialized. Please check bot configuration."
+59 -10
View File
@@ -6,12 +6,17 @@ Handles random number generation between 1 and X (default 100)
import random
import re
from typing import Optional
from .base_command import BaseCommand
from ..models import MeshMessage
class RollCommand(BaseCommand):
"""Handles random number rolling commands"""
"""Handles random number rolling commands.
This command generates a random number between 1 and a specified maximum (default 100).
It supports syntax like 'roll' or 'roll 50'.
"""
# Plugin metadata
name = "roll"
@@ -20,10 +25,24 @@ class RollCommand(BaseCommand):
category = "games"
def get_help_text(self) -> str:
"""Get help text for the roll command.
Returns:
str: The help text for this command.
"""
return self.translate('commands.roll.help')
def matches_keyword(self, message: MeshMessage) -> bool:
"""Override to handle roll-specific matching"""
"""Override to handle roll-specific matching.
Custom matching logic to support variable maximums (e.g., "roll 50").
Args:
message: The message to check for a match.
Returns:
bool: True if the message matches the roll command syntax, False otherwise.
"""
content = message.content.strip().lower()
# Handle command-style messages
@@ -46,11 +65,16 @@ class RollCommand(BaseCommand):
return False
def parse_roll_notation(self, roll_input: str) -> int:
"""
Parse roll notation and return the maximum number
Supports: 50, 100, 1000, etc.
Returns the maximum number or None if invalid
def parse_roll_notation(self, roll_input: str) -> Optional[int]:
"""Parse roll notation and return the maximum number.
Supports inputs like: 50, 100, 1000.
Args:
roll_input: The string part containing the number.
Returns:
Optional[int]: The maximum number if valid, None otherwise.
"""
roll_input = roll_input.strip()
@@ -65,15 +89,40 @@ class RollCommand(BaseCommand):
return None
def roll_number(self, max_num: int) -> int:
"""Roll a random number between 1 and max_num (inclusive)"""
"""Roll a random number between 1 and max_num (inclusive).
Args:
max_num: The maximum possible value.
Returns:
int: The generated random number.
"""
return random.randint(1, max_num)
def format_roll_result(self, max_num: int, result: int) -> str:
"""Format roll result into a readable string"""
"""Format roll result into a readable string.
Args:
max_num: The maximum number for the roll.
result: The actual rolled number.
Returns:
str: The formatted result string.
"""
return self.translate('commands.roll.result', max=max_num, result=result)
async def execute(self, message: MeshMessage) -> bool:
"""Execute the roll command"""
"""Execute the roll command.
Parses the maximum number (if provided), generates a random number,
and sends the result to the user.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
content = message.content.strip()
# Handle command-style messages
+25 -5
View File
@@ -28,10 +28,22 @@ class SatpassCommand(BaseCommand):
}
def __init__(self, bot):
"""Initialize the satpass command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
async def execute(self, message: MeshMessage) -> bool:
"""Execute the satpass command"""
"""Execute the satpass command.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
try:
# Check if user provided a satellite number
content = message.content.strip()
@@ -77,8 +89,12 @@ class SatpassCommand(BaseCommand):
await self.send_response(message, error_msg)
return False
def _get_help_text(self):
"""Get detailed help text with shortcuts"""
def _get_help_text(self) -> str:
"""Get detailed help text with shortcuts.
Returns:
str: Detailed help text including shortcuts.
"""
shortcuts_text = self.translate('commands.satpass.help_header')
# Group shortcuts by category for better organization
@@ -111,6 +127,10 @@ class SatpassCommand(BaseCommand):
return shortcuts_text
def get_help_text(self):
"""Get help text for this command"""
def get_help_text(self) -> str:
"""Get help text for this command.
Returns:
str: The help text for this command.
"""
return self.translate('commands.satpass.description')
+29 -2
View File
@@ -9,7 +9,11 @@ from ..models import MeshMessage
class SolarCommand(BaseCommand):
"""Command to get solar conditions"""
"""Command to get solar conditions.
Provides information about current solar activity (SFI, sunspots, A-index, K-index)
and improved HF band conditions.
"""
# Plugin metadata
name = "solar"
@@ -18,10 +22,24 @@ class SolarCommand(BaseCommand):
category = "solar"
def __init__(self, bot):
"""Initialize the solar command.
Args:
bot: The MeshCoreBot instance.
"""
super().__init__(bot)
async def execute(self, message: MeshMessage) -> bool:
"""Execute the solar command"""
"""Execute the solar command.
Retrieves solar conditions and sends a formatted response to the user.
Args:
message: The message that triggered the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
try:
# Get solar conditions (more readable format)
solar_info = solar_conditions()
@@ -32,7 +50,16 @@ class SolarCommand(BaseCommand):
# Use the unified send_response method
return await self.send_response(message, response)
except Exception as e:
error_msg = self.translate('commands.solar.error', error=str(e))
await self.send_response(message, error_msg)
return False
def get_help_text(self) -> str:
"""Get help text for this command.
Returns:
str: The help text for this command.
"""
return self.translate('commands.solar.help')
+91 -21
View File
@@ -13,7 +13,11 @@ from ..models import MeshMessage
class StatsCommand(BaseCommand):
"""Handles the stats command with comprehensive data collection"""
"""Handles the stats command with comprehensive data collection.
This command tracks usage statistics including messages, commands, and routing paths.
It provides insights into bot activity and network performance over the last 24 hours.
"""
# Plugin metadata
name = "stats"
@@ -26,8 +30,8 @@ class StatsCommand(BaseCommand):
self._load_config()
self._init_stats_tables()
def _load_config(self):
"""Load configuration settings for stats command"""
def _load_config(self) -> None:
"""Load configuration settings for stats command."""
self.stats_enabled = self.get_config_value('Stats_Command', 'stats_enabled', fallback=True, value_type='bool')
self.data_retention_days = self.get_config_value('Stats_Command', 'data_retention_days', fallback=7, value_type='int')
self.auto_cleanup = self.get_config_value('Stats_Command', 'auto_cleanup', fallback=True, value_type='bool')
@@ -35,8 +39,12 @@ class StatsCommand(BaseCommand):
self.track_command_details = self.get_config_value('Stats_Command', 'track_command_details', fallback=True, value_type='bool')
self.anonymize_users = self.get_config_value('Stats_Command', 'anonymize_users', fallback=False, value_type='bool')
def _init_stats_tables(self):
"""Initialize database tables for stats tracking"""
def _init_stats_tables(self) -> None:
"""Initialize database tables for stats tracking.
Creates tables for message stats, command stats, and path stats if they
don't already exist. Also sets up necessary indexes for performance.
"""
try:
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
@@ -104,8 +112,12 @@ class StatsCommand(BaseCommand):
self.logger.error(f"Failed to initialize stats tables: {e}")
raise
def record_message(self, message: MeshMessage):
"""Record a message in the stats database"""
def record_message(self, message: MeshMessage) -> None:
"""Record a message in the stats database.
Args:
message: The message to record statistics for.
"""
if not self.stats_enabled or not self.track_all_messages:
return
@@ -138,8 +150,14 @@ class StatsCommand(BaseCommand):
except Exception as e:
self.logger.error(f"Error recording message stats: {e}")
def record_command(self, message: MeshMessage, command_name: str, response_sent: bool = True):
"""Record a command execution in the stats database"""
def record_command(self, message: MeshMessage, command_name: str, response_sent: bool = True) -> None:
"""Record a command execution in the stats database.
Args:
message: The message that triggered the command.
command_name: The name of the command executed.
response_sent: Whether a response was sent back to the user.
"""
if not self.stats_enabled or not self.track_command_details:
return
@@ -169,8 +187,12 @@ class StatsCommand(BaseCommand):
except Exception as e:
self.logger.error(f"Error recording command stats: {e}")
def record_path_stats(self, message: MeshMessage):
"""Record path statistics for longest path tracking"""
def record_path_stats(self, message: MeshMessage) -> None:
"""Record path statistics for longest path tracking.
Args:
message: The message containing path information.
"""
if not self.stats_enabled or not self.track_all_messages:
return
@@ -212,7 +234,14 @@ class StatsCommand(BaseCommand):
self.logger.error(f"Error recording path stats: {e}")
def _is_valid_path_format(self, path: str) -> bool:
"""Check if path contains actual node IDs rather than descriptive text"""
"""Check if path contains actual node IDs rather than descriptive text.
Args:
path: The path string to validate.
Returns:
bool: True if the path structure appears valid, False otherwise.
"""
if not path:
return False
@@ -234,7 +263,14 @@ class StatsCommand(BaseCommand):
return False
def _format_path_for_display(self, path: str) -> str:
"""Format path string for display (e.g., '75,24,1d,5f,bd')"""
"""Format path string for display (e.g., '75,24,1d,5f,bd').
Args:
path: The raw path string.
Returns:
str: The formatted path string.
"""
if not path:
return "Direct"
@@ -262,7 +298,17 @@ class StatsCommand(BaseCommand):
return self.translate('commands.stats.help')
async def execute(self, message: MeshMessage) -> bool:
"""Execute the stats command"""
"""Execute the stats command.
Handles subcommands for messages, channels, and paths, or shows basic stats
if no subcommand is provided.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
if not self.stats_enabled:
await self.send_response(message, self.translate('commands.stats.disabled'))
return False
@@ -302,7 +348,11 @@ class StatsCommand(BaseCommand):
return False
async def _get_basic_stats(self) -> str:
"""Get basic bot statistics"""
"""Get basic bot statistics.
Returns:
str: Formatted string containing basic statistics (commands, top user, etc.).
"""
try:
# Get time window (24 hours ago)
now = int(time.time())
@@ -367,7 +417,11 @@ class StatsCommand(BaseCommand):
return self.translate('commands.stats.error', error=str(e))
async def _get_bot_user_leaderboard(self) -> str:
"""Get leaderboard for bot users (people who triggered bot responses)"""
"""Get leaderboard for bot users (people who triggered bot responses).
Returns:
str: Formatted leaderboard string.
"""
try:
# Get time window (24 hours ago)
now = int(time.time())
@@ -404,7 +458,11 @@ class StatsCommand(BaseCommand):
return self.translate('commands.stats.error_bot_users', error=str(e))
async def _get_channel_leaderboard(self) -> str:
"""Get leaderboard for channel message activity"""
"""Get leaderboard for channel message activity.
Returns:
str: Formatted leaderboard string.
"""
try:
# Get time window (24 hours ago)
now = int(time.time())
@@ -446,7 +504,11 @@ class StatsCommand(BaseCommand):
return self.translate('commands.stats.error_channels', error=str(e))
async def _get_path_leaderboard(self) -> str:
"""Get leaderboard for longest paths seen"""
"""Get leaderboard for longest paths seen.
Returns:
str: Formatted leaderboard string.
"""
try:
# Get time window (24 hours ago)
now = int(time.time())
@@ -497,8 +559,12 @@ class StatsCommand(BaseCommand):
self.logger.error(f"Error getting path leaderboard: {e}")
return self.translate('commands.stats.error_paths', error=str(e))
def cleanup_old_stats(self, days_to_keep: int = 7):
"""Clean up old stats data to prevent database bloat"""
def cleanup_old_stats(self, days_to_keep: int = 7) -> None:
"""Clean up old stats data to prevent database bloat.
Args:
days_to_keep: Number of days of data to retain.
"""
try:
cutoff_time = int(time.time()) - (days_to_keep * 24 * 60 * 60)
@@ -527,7 +593,11 @@ class StatsCommand(BaseCommand):
self.logger.error(f"Error cleaning up old stats: {e}")
def get_stats_summary(self) -> Dict[str, Any]:
"""Get a summary of all stats data"""
"""Get a summary of all stats data.
Returns:
Dict[str, Any]: Dictionary containing summary statistics.
"""
try:
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
+26 -4
View File
@@ -9,7 +9,11 @@ from ..models import MeshMessage
class SunCommand(BaseCommand):
"""Command to get sun information"""
"""Command to get sun information.
Calculates and displays sunrise and sunset times for the bot's configured location
or a default location.
"""
# Plugin metadata
name = "sun"
@@ -18,10 +22,24 @@ class SunCommand(BaseCommand):
category = "solar"
def __init__(self, bot):
"""Initialize the sun command.
Args:
bot: The MeshCoreBot instance.
"""
super().__init__(bot)
async def execute(self, message: MeshMessage) -> bool:
"""Execute the sun command"""
"""Execute the sun command.
Calculates sun events and sends the information to the user.
Args:
message: The message that triggered the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
try:
# Get sun information using default location
sun_info = get_sun()
@@ -34,6 +52,10 @@ class SunCommand(BaseCommand):
error_msg = self.translate('commands.sun.error', error=str(e))
return await self.send_response(message, error_msg)
def get_help_text(self):
"""Get help text for this command"""
def get_help_text(self) -> str:
"""Get help text for this command.
Returns:
str: The help text for this command.
"""
return self.translate('commands.sun.help')
+136 -17
View File
@@ -14,7 +14,11 @@ from ..utils import calculate_distance
class TestCommand(BaseCommand):
"""Handles the test command"""
"""Handles the test command.
Responds to 'test' or 't' with connection info. Supports an optional phrase.
Can utilize repeater geographic location data to estimate path distance.
"""
# Plugin metadata
name = "test"
@@ -53,10 +57,22 @@ class TestCommand(BaseCommand):
self.logger.warning(f"Error reading bot location from config: {e}")
def get_help_text(self) -> str:
"""Get help text for the command.
Returns:
str: Help text string.
"""
return self.translate('commands.test.help')
def clean_content(self, content: str) -> str:
"""Clean content by removing control characters and normalizing whitespace"""
"""Clean content by removing control characters and normalizing whitespace.
Args:
content: The raw message content.
Returns:
str: Cleaned and normalized content string.
"""
import re
# Remove control characters (except newline, tab, carriage return)
cleaned = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', content)
@@ -65,7 +81,16 @@ class TestCommand(BaseCommand):
return cleaned
def matches_keyword(self, message: MeshMessage) -> bool:
"""Override to implement special test keyword matching with optional phrase"""
"""Override to implement special test keyword matching with optional phrase.
Matches 'test', 't', 'test <phrase>', or 't <phrase>'.
Args:
message: The message to check.
Returns:
bool: True if the message matches the keyword patterns.
"""
# Clean content to remove control characters and normalize whitespace
content = self.clean_content(message.content)
@@ -89,15 +114,26 @@ class TestCommand(BaseCommand):
return False
def get_response_format(self) -> str:
"""Get the response format from config"""
def get_response_format(self) -> Optional[str]:
"""Get the response format from config.
Returns:
Optional[str]: The configured response format string, or None if not set.
"""
if self.bot.config.has_section('Keywords'):
format_str = self.bot.config.get('Keywords', 'test', fallback=None)
return self._strip_quotes_from_config(format_str) if format_str else None
return None
def _extract_path_node_ids(self, message: MeshMessage) -> List[str]:
"""Extract path node IDs from message path string"""
"""Extract path node IDs from message path string.
Args:
message: The message object containing the path.
Returns:
List[str]: List of valid 2-character hex node IDs.
"""
if not message.path:
return []
@@ -136,7 +172,15 @@ class TestCommand(BaseCommand):
def _lookup_repeater_location(self, node_id: str, path_context: Optional[List[str]] = None) -> Optional[Tuple[float, float]]:
"""Look up repeater location for a node ID using geographic proximity selection when path context is available"""
"""Look up repeater location for a node ID using geographic proximity selection when path context is available.
Args:
node_id: The node ID to look up.
path_context: Optional list of all node IDs in the path for context-aware selection.
Returns:
Optional[Tuple[float, float]]: (latitude, longitude) or None if not found.
"""
try:
if not hasattr(self.bot, 'db_manager'):
return None
@@ -196,7 +240,11 @@ class TestCommand(BaseCommand):
return None
def _get_sender_location(self) -> Optional[Tuple[float, float]]:
"""Get sender location from current message if available"""
"""Get sender location from current message if available.
Returns:
Optional[Tuple[float, float]]: (latitude, longitude) or None if unavailable/error.
"""
try:
if not hasattr(self, '_current_message') or not self._current_message:
return None
@@ -227,7 +275,14 @@ class TestCommand(BaseCommand):
return None
def _calculate_recency_weighted_scores(self, repeaters: List[Dict[str, Any]]) -> List[Tuple[Dict[str, Any], float]]:
"""Calculate recency-weighted scores for repeaters (0.0 to 1.0, higher = more recent)"""
"""Calculate recency-weighted scores for repeaters (0.0 to 1.0, higher = more recent).
Args:
repeaters: List of repeater dictionaries.
Returns:
List[Tuple[Dict[str, Any], float]]: List of (repeater, score) tuples sorting by score descending.
"""
scored_repeaters = []
now = datetime.now()
@@ -274,7 +329,14 @@ class TestCommand(BaseCommand):
return scored_repeaters
def _get_node_location_simple(self, node_id: str) -> Optional[Tuple[float, float]]:
"""Simple lookup without proximity selection - used for reference nodes"""
"""Simple lookup without proximity selection - used for reference nodes.
Args:
node_id: The node ID to look up.
Returns:
Optional[Tuple[float, float]]: (latitude, longitude) or None if not found.
"""
try:
if not hasattr(self.bot, 'db_manager'):
return None
@@ -305,7 +367,17 @@ class TestCommand(BaseCommand):
return None
def _select_by_path_proximity(self, repeaters: List[Dict[str, Any]], node_id: str, path_context: List[str], sender_location: Optional[Tuple[float, float]] = None) -> Optional[Dict[str, Any]]:
"""Select repeater based on proximity to previous/next nodes in path"""
"""Select repeater based on proximity to previous/next nodes in path.
Args:
repeaters: List of candidate repeaters.
node_id: Current node ID being resolved.
path_context: Full path of node IDs.
sender_location: Optional sender location for first hop optimization.
Returns:
Optional[Dict[str, Any]]: Selected repeater dict or None.
"""
try:
# Filter by recency first
scored_repeaters = self._calculate_recency_weighted_scores(repeaters)
@@ -366,7 +438,16 @@ class TestCommand(BaseCommand):
return None
def _select_by_dual_proximity(self, repeaters: List[Dict[str, Any]], prev_location: Tuple[float, float], next_location: Tuple[float, float]) -> Optional[Dict[str, Any]]:
"""Select repeater based on proximity to both previous and next nodes"""
"""Select repeater based on proximity to both previous and next nodes.
Args:
repeaters: List of candidate repeaters.
prev_location: Coordinates of previous node.
next_location: Coordinates of next node.
Returns:
Optional[Dict[str, Any]]: Best matching repeater or None.
"""
scored_repeaters = self._calculate_recency_weighted_scores(repeaters)
min_recency_threshold = 0.01
scored_repeaters = [(r, score) for r, score in scored_repeaters if score >= min_recency_threshold]
@@ -410,7 +491,16 @@ class TestCommand(BaseCommand):
return best_repeater
def _select_by_single_proximity(self, repeaters: List[Dict[str, Any]], reference_location: Tuple[float, float], direction: str = "unknown") -> Optional[Dict[str, Any]]:
"""Select repeater based on proximity to single reference node"""
"""Select repeater based on proximity to single reference node.
Args:
repeaters: List of candidate repeaters.
reference_location: Coordinates of reference node (sender, bot, next, previous).
direction: Direction indicator ('sender', 'bot', 'next', 'previous').
Returns:
Optional[Dict[str, Any]]: Best matching repeater or None.
"""
scored_repeaters = self._calculate_recency_weighted_scores(repeaters)
min_recency_threshold = 0.01
scored_repeaters = [(r, score) for r, score in scored_repeaters if score >= min_recency_threshold]
@@ -457,7 +547,14 @@ class TestCommand(BaseCommand):
return best_repeater
def _calculate_path_distance(self, message: MeshMessage) -> str:
"""Calculate total distance along path (sum of distances between consecutive repeaters with locations)"""
"""Calculate total distance along path (sum of distances between consecutive repeaters with locations).
Args:
message: The message containing the path.
Returns:
str: Formatted distance string used in response.
"""
node_ids = self._extract_path_node_ids(message)
if len(node_ids) < 2:
# Check if it's a direct connection
@@ -502,7 +599,14 @@ class TestCommand(BaseCommand):
return f"{total_distance:.1f}km ({valid_segments} segs)"
def _calculate_firstlast_distance(self, message: MeshMessage) -> str:
"""Calculate straight-line distance between first and last repeater in path"""
"""Calculate straight-line distance between first and last repeater in path.
Args:
message: The message containing the path.
Returns:
str: Formatted distance string used in response.
"""
node_ids = self._extract_path_node_ids(message)
if len(node_ids) < 2:
# Check if it's a direct connection
@@ -531,7 +635,15 @@ class TestCommand(BaseCommand):
return f"{distance:.1f}km"
def format_response(self, message: MeshMessage, response_format: str) -> str:
"""Override to handle phrase extraction"""
"""Override to handle phrase extraction.
Args:
message: The original message.
response_format: The format string.
Returns:
str: Formatted response string.
"""
# Clean content to remove control characters and normalize whitespace
content = self.clean_content(message.content)
@@ -580,7 +692,14 @@ class TestCommand(BaseCommand):
return response_format
async def execute(self, message: MeshMessage) -> bool:
"""Execute the test command"""
"""Execute the test command.
Args:
message: The input message trigger.
Returns:
bool: True if execution was successful.
"""
# Store the current message for use in location lookups
self._current_message = message
return await self.handle_keyword_match(message)
+47 -8
View File
@@ -20,10 +20,30 @@ class WebViewerCommand(BaseCommand):
category = "management"
def __init__(self, bot):
"""Initialize the webviewer command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
def get_help_text(self) -> str:
"""Get help text for the webviewer command.
Returns:
str: The help text for this command.
"""
return "Usage: webviewer <subcommand>\nSubcommands: status, reset, restart"
def matches_keyword(self, message: MeshMessage) -> bool:
"""Check if message starts with 'webviewer' keyword"""
"""Check if message starts with 'webviewer' keyword.
Args:
message: The received message.
Returns:
bool: True if matches, False otherwise.
"""
content = message.content.strip()
# Handle exclamation prefix
@@ -38,7 +58,14 @@ class WebViewerCommand(BaseCommand):
return False
async def execute(self, message: MeshMessage) -> bool:
"""Execute the webviewer command"""
"""Execute the webviewer command.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
content = message.content.strip()
# Handle exclamation prefix
@@ -64,8 +91,12 @@ class WebViewerCommand(BaseCommand):
return True
async def _handle_status(self, message: MeshMessage):
"""Handle status subcommand"""
async def _handle_status(self, message: MeshMessage) -> None:
"""Handle status subcommand.
Args:
message: The message that triggered the detailed status request.
"""
if not hasattr(self.bot, 'web_viewer_integration') or not self.bot.web_viewer_integration:
await self.bot.send_response("Web viewer integration not available")
return
@@ -91,8 +122,12 @@ class WebViewerCommand(BaseCommand):
await self.bot.send_response(status_text)
async def _handle_reset(self, message: MeshMessage):
"""Handle reset subcommand"""
async def _handle_reset(self, message: MeshMessage) -> None:
"""Handle reset subcommand.
Args:
message: The message that triggered the reset request.
"""
if not hasattr(self.bot, 'web_viewer_integration') or not self.bot.web_viewer_integration:
await self.bot.send_response("Web viewer integration not available")
return
@@ -103,8 +138,12 @@ class WebViewerCommand(BaseCommand):
else:
await self.bot.send_response("Bot integration not available")
async def _handle_restart(self, message: MeshMessage):
"""Handle restart subcommand"""
async def _handle_restart(self, message: MeshMessage) -> None:
"""Handle restart subcommand.
Args:
message: The message that triggered the restart request.
"""
if not hasattr(self.bot, 'web_viewer_integration') or not self.bot.web_viewer_integration:
await self.bot.send_response("Web viewer integration not available")
return
+93 -27
View File
@@ -43,7 +43,11 @@ from .utils import resolve_path
class MeshCoreBot:
"""MeshCore Bot using official meshcore package"""
"""MeshCore Bot using official meshcore package.
This class handles the core functionality of the bot, including connection management,
message processing initialization, and module coordination.
"""
def __init__(self, config_file: str = "config.ini"):
self.config_file = config_file
@@ -194,15 +198,23 @@ class MeshCoreBot:
"""Get bot root directory (where config.ini is located)"""
return Path(self.config_file).parent.resolve()
def load_config(self):
"""Load configuration from file"""
def load_config(self) -> None:
"""Load configuration from file.
Reads the configuration file specified in self.config_file. If the file
does not exist, a default configuration is created first.
"""
if not Path(self.config_file).exists():
self.create_default_config()
self.config.read(self.config_file)
def create_default_config(self):
"""Create default configuration file"""
def create_default_config(self) -> None:
"""Create default configuration file.
Writes a default 'config.ini' file to disk with standard settings
and comments explaining each option.
"""
default_config = """[Connection]
# Connection type: serial, ble, or tcp
# serial: Connect via USB serial port
@@ -547,8 +559,13 @@ use_zulu_time = false
# Note: Using print here since logger may not be initialized yet
print(f"Created default config file: {self.config_file}")
def setup_logging(self):
"""Setup logging configuration"""
def setup_logging(self) -> None:
"""Setup logging configuration.
Configures the logging system based on settings in the config file.
Sets up console and file handlers, formatters, and log levels for
both the bot and the underlying meshcore library.
"""
log_level = getattr(logging, self.config.get('Logging', 'log_level', fallback='INFO'))
# Create formatter
@@ -632,8 +649,12 @@ use_zulu_time = false
# Setup signal handlers for graceful shutdown
self._setup_signal_handlers()
def _setup_routing_capture(self):
"""Setup routing information capture for web viewer"""
def _setup_routing_capture(self) -> None:
"""Setup routing information capture for web viewer.
Initializes the mechanism to capture message routing information
if the web viewer integration is enabled.
"""
# Web viewer doesn't need complex routing capture
# It uses direct database access instead of complex integration
if not (hasattr(self, 'web_viewer_integration') and
@@ -642,8 +663,12 @@ use_zulu_time = false
self.logger.info("Web viewer routing capture setup complete")
def _setup_signal_handlers(self):
"""Setup signal handlers for graceful shutdown"""
def _setup_signal_handlers(self) -> None:
"""Setup signal handlers for graceful shutdown.
Registers handlers for SIGTERM and SIGINT to ensure the bot can
clean up resources and disconnect properly when stopped.
"""
def signal_handler(signum, frame):
self.logger.info(f"Received signal {signum}, initiating graceful shutdown...")
# Set shutdown event to break main loop
@@ -656,7 +681,14 @@ use_zulu_time = false
signal.signal(signal.SIGINT, signal_handler)
async def connect(self) -> bool:
"""Connect to MeshCore node using official package"""
"""Connect to MeshCore node using official package.
Establishes a connection to the mesh node via Serial, TCP, or BLE
based on the configuration.
Returns:
bool: True if connection was successful, False otherwise.
"""
try:
self.logger.info("Connecting to MeshCore node...")
@@ -710,7 +742,14 @@ use_zulu_time = false
return False
async def set_radio_clock(self) -> bool:
"""Set radio clock only if device time is earlier than current system time"""
"""Set radio clock if device time is earlier than system time.
Checks the connected device's time and updates it to match the system
time if the device is lagging behind.
Returns:
bool: True if check/update was successful (or not needed), False on error.
"""
try:
if not self.meshcore or not self.meshcore.is_connected:
self.logger.warning("Cannot set radio clock - not connected to device")
@@ -749,8 +788,12 @@ use_zulu_time = false
self.logger.warning(f"Error checking/setting radio clock: {e}")
return False
async def wait_for_contacts(self):
"""Wait for contacts to be loaded"""
async def wait_for_contacts(self) -> None:
"""Wait for contacts to be loaded from the device.
Polls the device for contact list or waits for automatic loading.
Times out after 30 seconds if contacts are not loaded.
"""
self.logger.info("Waiting for contacts to load...")
# Try to manually load contacts first
@@ -781,8 +824,12 @@ use_zulu_time = false
self.logger.warning(f"Contacts not loaded after {max_wait} seconds, proceeding anyway")
async def setup_message_handlers(self):
"""Setup event handlers for messages"""
async def setup_message_handlers(self) -> None:
"""Setup event handlers for messages.
Registers callbacks for various meshcore events including contact messages,
channel messages, RF data, and raw data packets.
"""
# Handle contact messages (DMs)
async def on_contact_message(event, metadata=None):
await self.message_handler.handle_contact_message(event, metadata)
@@ -827,8 +874,12 @@ use_zulu_time = false
self.logger.info("Message handlers setup complete")
async def start(self):
"""Start the bot"""
async def start(self) -> None:
"""Start the bot.
Initiates the connection to the node, sets up scheduling, services,
and starts the main execution loop.
"""
self.logger.info("Starting MeshCore Bot...")
# Connect to MeshCore node
@@ -903,8 +954,12 @@ use_zulu_time = false
finally:
await self.stop()
async def stop(self):
"""Stop the bot"""
async def stop(self) -> None:
"""Stop the bot.
Performs graceful shutdown by stopping services, scheduling, and
disconnecting from the mesh node.
"""
try:
self.logger.info("Stopping MeshCore Bot...")
except (AttributeError, TypeError):
@@ -942,10 +997,13 @@ use_zulu_time = false
print("Bot stopped")
async def get_system_health(self) -> Dict[str, Any]:
"""Aggregate health status from all components
"""Aggregate health status from all components.
Collects status information from the meshcore connection, database,
services, and other components to provide a system health report.
Returns:
Dictionary containing overall health status and component details
Dict[str, Any]: Dictionary containing overall health status and component details.
"""
health = {
'status': 'healthy',
@@ -1029,8 +1087,12 @@ use_zulu_time = false
return health
def _cleanup_web_viewer(self):
"""Cleanup web viewer on exit"""
def _cleanup_web_viewer(self) -> None:
"""Cleanup web viewer resources on exit.
Called by atexit handler to ensure the web viewer process is terminated
properly when the bot shuts down.
"""
try:
if hasattr(self, 'web_viewer_integration') and self.web_viewer_integration:
# Web viewer has simpler cleanup
@@ -1045,8 +1107,12 @@ use_zulu_time = false
except (AttributeError, TypeError):
print(f"Error during web viewer cleanup: {e}")
async def send_startup_advert(self):
"""Send a startup advert if enabled in config"""
async def send_startup_advert(self) -> None:
"""Send a startup advertisement if configured.
Sends a 'bot online' status message to the mesh network. Can be configured
as a local zero-hop broadcast or a flood message.
"""
try:
# Check if startup advert is enabled
startup_advert = self.config.get('Bot', 'startup_advert', fallback='false').lower()
+114 -27
View File
@@ -13,7 +13,11 @@ from pathlib import Path
class DBManager:
"""Generalized database manager for common operations"""
"""Generalized database manager for common operations.
Handles database initialization, schema management, caching, and metadata storage.
Enforces a table whitelist for security.
"""
# Whitelist of allowed tables for security
ALLOWED_TABLES = {
@@ -29,14 +33,18 @@ class DBManager:
'purging_log', # Repeater manager
}
def __init__(self, bot, db_path: str = "meshcore_bot.db"):
def __init__(self, bot: Any, db_path: str = "meshcore_bot.db"):
self.bot = bot
self.logger = bot.logger
self.db_path = db_path
self._init_database()
def _init_database(self):
"""Initialize the SQLite database with required tables"""
def _init_database(self) -> None:
"""Initialize the SQLite database with required tables.
Creates all necessary tables including cache, metadata, feed subscriptions,
activity logs, and proper indexes for performance optimization.
"""
try:
with sqlite3.connect(str(self.db_path), timeout=30.0) as conn:
cursor = conn.cursor()
@@ -213,7 +221,15 @@ class DBManager:
# Geocoding cache methods
def get_cached_geocoding(self, query: str) -> Tuple[Optional[float], Optional[float]]:
"""Get cached geocoding result for a query"""
"""Get cached geocoding result for a query.
Args:
query: The geocoding query string.
Returns:
Tuple[Optional[float], Optional[float]]: A tuple containing (latitude, longitude)
if found and valid, otherwise (None, None).
"""
try:
with sqlite3.connect(str(self.db_path), timeout=30.0) as conn:
cursor = conn.cursor()
@@ -229,8 +245,15 @@ class DBManager:
self.logger.error(f"Error getting cached geocoding: {e}")
return None, None
def cache_geocoding(self, query: str, latitude: float, longitude: float, cache_hours: int = 720):
"""Cache geocoding result for future use (default: 30 days)"""
def cache_geocoding(self, query: str, latitude: float, longitude: float, cache_hours: int = 720) -> None:
"""Cache geocoding result for future use.
Args:
query: The geocoding query string.
latitude: Latitude coordinate.
longitude: Longitude coordinate.
cache_hours: Expiration time in hours (default: 720 hours / 30 days).
"""
try:
# Validate cache_hours to prevent SQL injection
if not isinstance(cache_hours, int) or cache_hours < 1 or cache_hours > 87600: # Max 10 years
@@ -250,7 +273,15 @@ class DBManager:
# Generic cache methods
def get_cached_value(self, cache_key: str, cache_type: str) -> Optional[str]:
"""Get cached value for a key and type"""
"""Get cached value for a key and type.
Args:
cache_key: Unique key for the cached item.
cache_type: Category or type identifier for the cache.
Returns:
Optional[str]: Cached string value if found and valid, None otherwise.
"""
try:
with sqlite3.connect(str(self.db_path), timeout=30.0) as conn:
cursor = conn.cursor()
@@ -266,8 +297,15 @@ class DBManager:
self.logger.error(f"Error getting cached value: {e}")
return None
def cache_value(self, cache_key: str, cache_value: str, cache_type: str, cache_hours: int = 24):
"""Cache a value for future use"""
def cache_value(self, cache_key: str, cache_value: str, cache_type: str, cache_hours: int = 24) -> None:
"""Cache a value for future use.
Args:
cache_key: Unique key for the cached item.
cache_value: String value to cache.
cache_type: Category or type identifier.
cache_hours: Expiration time in hours (default: 24 hours).
"""
try:
# Validate cache_hours to prevent SQL injection
if not isinstance(cache_hours, int) or cache_hours < 1 or cache_hours > 87600: # Max 10 years
@@ -286,7 +324,15 @@ class DBManager:
self.logger.error(f"Error caching value: {e}")
def get_cached_json(self, cache_key: str, cache_type: str) -> Optional[Dict]:
"""Get cached JSON value for a key and type"""
"""Get cached JSON value for a key and type.
Args:
cache_key: Unique key for the cached item.
cache_type: Category or type identifier.
Returns:
Optional[Dict]: Parsed JSON dictionary if found and valid, None otherwise.
"""
cached_value = self.get_cached_value(cache_key, cache_type)
if cached_value:
try:
@@ -296,8 +342,15 @@ class DBManager:
return None
return None
def cache_json(self, cache_key: str, cache_value: Dict, cache_type: str, cache_hours: int = 720):
"""Cache a JSON value for future use (default: 30 days for geolocation)"""
def cache_json(self, cache_key: str, cache_value: Dict, cache_type: str, cache_hours: int = 720) -> None:
"""Cache a JSON value for future use.
Args:
cache_key: Unique key for the cached item.
cache_value: Dictionary to serialize and cache.
cache_type: Category or type identifier.
cache_hours: Expiration time in hours (default: 720 hours / 30 days).
"""
try:
json_str = json.dumps(cache_value)
self.cache_value(cache_key, json_str, cache_type, cache_hours)
@@ -305,8 +358,12 @@ class DBManager:
self.logger.error(f"Error caching JSON value: {e}")
# Cache cleanup methods
def cleanup_expired_cache(self):
"""Remove expired cache entries from all cache tables"""
def cleanup_expired_cache(self) -> None:
"""Remove expired cache entries from all cache tables.
Deletes rows from geocoding_cache and generic_cache where the
expiration timestamp has passed.
"""
try:
with sqlite3.connect(str(self.db_path), timeout=30.0) as conn:
cursor = conn.cursor()
@@ -328,7 +385,7 @@ class DBManager:
except Exception as e:
self.logger.error(f"Error cleaning up expired cache: {e}")
def cleanup_geocoding_cache(self):
def cleanup_geocoding_cache(self) -> None:
"""Remove expired geocoding cache entries"""
try:
with sqlite3.connect(str(self.db_path), timeout=30.0) as conn:
@@ -377,8 +434,11 @@ class DBManager:
self.logger.error(f"Error getting database stats: {e}")
return {}
def vacuum_database(self):
"""Optimize database by reclaiming unused space"""
def vacuum_database(self) -> None:
"""Optimize database by reclaiming unused space.
Executes the VACUUM command to rebuild the database file and reduce size.
"""
try:
with sqlite3.connect(str(self.db_path), timeout=30.0) as conn:
conn.execute("VACUUM")
@@ -387,8 +447,16 @@ class DBManager:
self.logger.error(f"Error vacuuming database: {e}")
# Table management methods
def create_table(self, table_name: str, schema: str):
"""Create a custom table with the given schema (whitelist-protected)"""
def create_table(self, table_name: str, schema: str) -> None:
"""Create a custom table with the given schema.
Args:
table_name: Name of the table to create (must be whitelist-protected).
schema: SQL schema definition for the table columns.
Raises:
ValueError: If table_name is not in the allowed whitelist.
"""
try:
# Validate table name against whitelist
if table_name not in self.ALLOWED_TABLES:
@@ -408,8 +476,15 @@ class DBManager:
self.logger.error(f"Error creating table {table_name}: {e}")
raise
def drop_table(self, table_name: str):
"""Drop a table (whitelist-protected, use with extreme caution)"""
def drop_table(self, table_name: str) -> None:
"""Drop a table.
Args:
table_name: Name of the table to drop (must be whitelist-protected).
Raises:
ValueError: If table_name is not in the allowed whitelist.
"""
try:
# Validate table name against whitelist
if table_name not in self.ALLOWED_TABLES:
@@ -458,8 +533,13 @@ class DBManager:
return 0
# Bot metadata methods
def set_metadata(self, key: str, value: str):
"""Set a metadata value for the bot"""
def set_metadata(self, key: str, value: str) -> None:
"""Set a metadata value for the bot.
Args:
key: Metadata key name.
value: Metadata string value.
"""
try:
with sqlite3.connect(str(self.db_path), timeout=30.0) as conn:
cursor = conn.cursor()
@@ -472,7 +552,14 @@ class DBManager:
self.logger.error(f"Error setting metadata {key}: {e}")
def get_metadata(self, key: str) -> Optional[str]:
"""Get a metadata value for the bot"""
"""Get a metadata value for the bot.
Args:
key: Metadata key to retrieve.
Returns:
Optional[str]: Value string if found, None otherwise.
"""
try:
with sqlite3.connect(str(self.db_path), timeout=30.0) as conn:
cursor = conn.cursor()
@@ -496,7 +583,7 @@ class DBManager:
return None
return None
def set_bot_start_time(self, start_time: float):
def set_bot_start_time(self, start_time: float) -> None:
"""Set bot start time in metadata"""
self.set_metadata('start_time', str(start_time))
@@ -510,7 +597,7 @@ class DBManager:
conn.row_factory = sqlite3.Row
return conn
def set_system_health(self, health_data: Dict[str, Any]):
def set_system_health(self, health_data: Dict[str, Any]) -> None:
"""Store system health data in metadata"""
try:
import json
+62 -9
View File
@@ -18,7 +18,13 @@ from .security_utils import sanitize_input
class MessageHandler:
"""Handles incoming messages and routes them to command processors"""
"""Handles incoming messages and routes them to command processors.
This class is responsible for processing various types of MeshCore events,
including contact messages (DMs), raw data packets, advertisement packets,
and RF log data. It also maintains caches for SNR/RSSI data and correlates
messages with routing information.
"""
def __init__(self, bot):
self.bot = bot
@@ -53,7 +59,15 @@ class MessageHandler:
self.logger.info(f"RF Data Correlation: timeout={self.rf_data_timeout}s, enhanced={self.enhanced_correlation}")
async def handle_contact_message(self, event, metadata=None):
"""Handle incoming contact message (DM)"""
"""Handle incoming contact message (DM).
Processes direct messages, extracts path information, correlates with
RF data for signal metrics (SNR/RSSI), and forwards to the command processor.
Args:
event: The MeshCore event object containing the message payload.
metadata: Optional metadata dictionary associated with the event.
"""
try:
payload = event.payload
@@ -338,7 +352,15 @@ class MessageHandler:
self.logger.error(f"Error handling contact message: {e}")
async def handle_raw_data(self, event, metadata=None):
"""Handle raw data events (full packet data from debug mode)"""
"""Handle raw data events (full packet data from debug mode).
Processes raw packet data, attempts to decode it, and if successful,
checking if it's an advertisement packet to track.
Args:
event: The MeshCore event object containing the raw data payload.
metadata: Optional metadata dictionary.
"""
try:
payload = event.payload
self.logger.info(f"📦 RAW_DATA EVENT RECEIVED: {payload}")
@@ -381,7 +403,15 @@ class MessageHandler:
self.logger.error(traceback.format_exc())
async def _process_advertisement_packet(self, packet_info: Dict, metadata=None):
"""Process advertisement packets for complete repeater tracking"""
"""Process advertisement packets for complete repeater tracking.
Extracts node information, location data, and routing path from
advertisement packets and updates the repeater database.
Args:
packet_info: Dictionary containing decoded packet information.
metadata: Optional metadata dictionary with signal metrics.
"""
try:
# Check if this is an advertisement packet
if (packet_info.get('payload_type') == 'ADVERT' or
@@ -519,7 +549,15 @@ class MessageHandler:
self.logger.error(f"Error processing advertisement packet: {e}")
async def handle_rf_log_data(self, event, metadata=None):
"""Handle RF log data events to cache SNR information and store raw packet data"""
"""Handle RF log data events to cache SNR information and store raw packet data.
Captures low-level RF information (SNR, RSSI) and raw packet data to
correlate with higher-level messages for detailed signal reporting.
Args:
event: The MeshCore event object containing RF data.
metadata: Optional metadata dictionary.
"""
try:
payload = event.payload
@@ -662,8 +700,19 @@ class MessageHandler:
except Exception as e:
self.logger.error(f"Error handling RF log data: {e}")
def extract_path_from_raw_hex(self, raw_hex, expected_hops):
"""Extract path information directly from raw hex data"""
def extract_path_from_raw_hex(self, raw_hex: str, expected_hops: int) -> Optional[str]:
"""Extract path information directly from raw hex data.
Attempts to find a sequence of node IDs in the raw packet data that matches
the expected number of hops.
Args:
raw_hex: Raw packet data as a hex string.
expected_hops: The expected number of hops in the path.
Returns:
Optional[str]: Comma-separated path string if found, None otherwise.
"""
try:
if not raw_hex or len(raw_hex) < 20:
return None
@@ -723,8 +772,12 @@ class MessageHandler:
self.logger.debug(f"Error extracting path from raw hex: {e}")
return None
def _cleanup_stale_cache_entries(self, current_time=None):
"""Remove stale entries from RF data caches and enforce maximum size limits"""
def _cleanup_stale_cache_entries(self, current_time: Optional[float] = None) -> None:
"""Remove stale entries from RF data caches and enforce maximum size limits.
Args:
current_time: Optional timestamp to use as "now". Defaults to time.time().
"""
if current_time is None:
current_time = time.time()
+30 -17
View File
@@ -8,7 +8,12 @@ from typing import Dict, Any, Optional
class BaseServicePlugin(ABC):
"""Base class for background service plugins"""
"""Base class for background service plugins.
This class defines the interface for service plugins, which are long-running
background tasks that can interact with the bot and mesh network. It manages
service lifecycle (start/stop) and metadata.
"""
# Optional: Config section name (if different from class name)
# If not set, will be derived from class name (e.g., PacketCaptureService -> PacketCapture)
@@ -17,12 +22,11 @@ class BaseServicePlugin(ABC):
# Optional: Service description for metadata
description: str = ""
def __init__(self, bot):
"""
Initialize the service plugin
def __init__(self, bot: Any):
"""Initialize the service plugin.
Args:
bot: The MeshCoreBot instance
bot: The MeshCoreBot instance containing the service.
"""
self.bot = bot
self.logger = bot.logger
@@ -30,9 +34,8 @@ class BaseServicePlugin(ABC):
self._running = False
@abstractmethod
async def start(self):
"""
Start the service
async def start(self) -> None:
"""Start the service.
This method should:
- Setup event handlers if needed
@@ -42,9 +45,8 @@ class BaseServicePlugin(ABC):
pass
@abstractmethod
async def stop(self):
"""
Stop the service
async def stop(self) -> None:
"""Stop the service.
This method should:
- Clean up event handlers
@@ -54,11 +56,10 @@ class BaseServicePlugin(ABC):
pass
def get_metadata(self) -> Dict[str, Any]:
"""
Get service metadata
"""Get service metadata.
Returns:
Dictionary containing service metadata
Dict[str, Any]: Dictionary containing service metadata (name, status, etc.).
"""
return {
'name': self._derive_service_name(),
@@ -70,14 +71,22 @@ class BaseServicePlugin(ABC):
}
def _derive_service_name(self) -> str:
"""Derive service name from class name"""
"""Derive service name from class name.
Returns:
str: Derived service name (e.g., 'PacketCaptureService' -> 'packetcapture').
"""
class_name = self.__class__.__name__
if class_name.endswith('Service'):
return class_name[:-7].lower() # Remove 'Service' suffix and lowercase
return class_name.lower()
def _derive_config_section(self) -> str:
"""Derive config section name from class name"""
"""Derive config section name from class name.
Returns:
str: Derived config section name.
"""
if self.config_section:
return self.config_section
@@ -87,6 +96,10 @@ class BaseServicePlugin(ABC):
return class_name
def is_running(self) -> bool:
"""Check if the service is currently running"""
"""Check if the service is currently running.
Returns:
bool: True if the service is running, False otherwise.
"""
return self._running
+131 -35
View File
@@ -22,8 +22,10 @@ from ..enums import AdvertFlags, PayloadType
# Import HTTP client
try:
import aiohttp
AIOHTTP_AVAILABLE = True
except ImportError:
aiohttp = None
AIOHTTP_AVAILABLE = False
# Import cryptography for signature verification
try:
@@ -31,7 +33,7 @@ try:
CRYPTOGRAPHY_AVAILABLE = True
except ImportError:
CRYPTOGRAPHY_AVAILABLE = False
ed25519 = None
ed25519 = None # type: ignore
# Import private key utilities
from .packet_capture_utils import (
@@ -48,13 +50,22 @@ from ..utils import resolve_path
class MapUploaderService(BaseServicePlugin):
"""Map uploader service - uploads node adverts to map.meshcore.dev"""
"""Map uploader service.
Uploads node adverts relative to the MeshCore network to map.meshcore.dev.
Listens for ADVERT packets and uploads them to the centralized map service.
Handles signing of data using the device's private key to ensure authenticity.
"""
config_section = 'MapUploader' # Explicit config section
description = "Uploads node adverts to map.meshcore.dev"
def __init__(self, bot):
"""Initialize map uploader service"""
def __init__(self, bot: Any):
"""Initialize map uploader service.
Args:
bot: The bot instance.
"""
super().__init__(bot)
# Setup logging
@@ -133,7 +144,8 @@ class MapUploaderService(BaseServicePlugin):
self.radio_params: Dict[str, Any] = {}
# HTTP session
self.http_session: Optional[aiohttp.ClientSession] = None
# HTTP session
self.http_session: Optional[aiohttp.ClientSession] = None # type: ignore
# Event subscriptions
self.event_subscriptions = []
@@ -147,8 +159,8 @@ class MapUploaderService(BaseServicePlugin):
self.logger.info("Map uploader service initialized")
def _load_config(self):
"""Load configuration from bot's config"""
def _load_config(self) -> None:
"""Load configuration from bot's config."""
config = self.bot.config
# Check if enabled
@@ -175,18 +187,26 @@ class MapUploaderService(BaseServicePlugin):
self.verbose = config.getboolean('MapUploader', 'verbose', fallback=False)
@property
def meshcore(self):
"""Get meshcore connection from bot (always current)"""
def meshcore(self) -> Any:
"""Get meshcore connection from bot (always current).
Returns:
Any: The meshcore connection object or None.
"""
return self.bot.meshcore if self.bot else None
async def start(self):
"""Start the map uploader service"""
async def start(self) -> None:
"""Start the map uploader service.
Initializes connections, fetches device keys, and registers event handlers.
Checks for required dependencies (aiohttp, cryptography) before starting.
"""
if not self.enabled:
self.logger.info("Map uploader service is disabled")
return
# Check dependencies
if not aiohttp:
if not AIOHTTP_AVAILABLE:
self.logger.error("aiohttp is required for map uploader service. Install with: pip install aiohttp")
return
@@ -220,7 +240,7 @@ class MapUploaderService(BaseServicePlugin):
return
# Create HTTP session
self.http_session = aiohttp.ClientSession()
self.http_session = aiohttp.ClientSession() # type: ignore
# Setup event handlers
await self._setup_event_handlers()
@@ -229,8 +249,12 @@ class MapUploaderService(BaseServicePlugin):
self._running = True
self.logger.info("Map uploader service started")
async def stop(self):
"""Stop the map uploader service"""
async def stop(self) -> None:
"""Stop the map uploader service.
Closes connections to the map service and the bot's meshcore.
Cleans up resources and event subscriptions.
"""
self.logger.info("Stopping map uploader service...")
self.should_exit = True
@@ -256,8 +280,12 @@ class MapUploaderService(BaseServicePlugin):
self.logger.info("Map uploader service stopped")
async def _fetch_private_key(self):
"""Fetch private key from device if not already loaded"""
async def _fetch_private_key(self) -> None:
"""Fetch private key from device if not already loaded.
Attempts to read the private key from the connected MeshCore device
for signing map uploads. This is required for valid uploads.
"""
if self.private_key_hex:
self.logger.debug("Private key already loaded from file")
return
@@ -279,8 +307,12 @@ class MapUploaderService(BaseServicePlugin):
except Exception as e:
self.logger.error(f"Error fetching private key from device: {e}")
async def _fetch_device_info(self):
"""Fetch device info (public key and radio parameters)"""
async def _fetch_device_info(self) -> None:
"""Fetch device info (public key and radio parameters).
Retrieve public key and LoRa radio settings (frequency, coding rate, etc.)
from the device self_info. These parameters are sent with map uploads.
"""
if not self.meshcore or not self.meshcore.is_connected:
self.logger.warning("Cannot fetch device info: not connected")
return
@@ -349,8 +381,11 @@ class MapUploaderService(BaseServicePlugin):
'bw': 0
}
async def _setup_event_handlers(self):
"""Setup event handlers for packet capture"""
async def _setup_event_handlers(self) -> None:
"""Setup event handlers for packet capture.
Subscribes to RX_LOG_DATA events to intercept packets for upload.
"""
if not self.meshcore:
return
@@ -367,13 +402,22 @@ class MapUploaderService(BaseServicePlugin):
self.logger.info("Map uploader event handlers registered")
def _cleanup_event_subscriptions(self):
"""Clean up event subscriptions"""
def _cleanup_event_subscriptions(self) -> None:
"""Clean up event subscriptions.
Clears the list of tracked subscriptions. The actual unsubscription
is handled by the meshcore library when the client disconnects,
but this clears our local tracking.
"""
# Note: meshcore library handles subscription cleanup automatically
self.event_subscriptions = []
async def _cleanup_old_seen_adverts(self, current_timestamp: int):
"""Clean up old entries from seen_adverts to prevent unbounded memory growth"""
async def _cleanup_old_seen_adverts(self, current_timestamp: int) -> None:
"""Clean up old entries from seen_adverts to prevent unbounded memory growth.
Args:
current_timestamp: The current timestamp from the latest packet.
"""
current_time = time.time()
# Only cleanup periodically (not on every packet)
@@ -419,8 +463,13 @@ class MapUploaderService(BaseServicePlugin):
f"seen_adverts grew too large, trimmed to 5000 most recent entries"
)
async def _handle_rx_log_data(self, event, metadata=None):
"""Handle RX log data events"""
async def _handle_rx_log_data(self, event: Any, metadata: Any = None) -> None:
"""Handle RX log data events.
Args:
event: The event object containing packet data.
metadata: Optional metadata for the event.
"""
try:
payload = event.payload
@@ -439,8 +488,15 @@ class MapUploaderService(BaseServicePlugin):
except Exception as e:
self.logger.error(f"Error handling RX log data: {e}", exc_info=True)
async def _process_packet(self, raw_hex: str):
"""Process a packet and upload if it's an ADVERT"""
async def _process_packet(self, raw_hex: str) -> None:
"""Process a packet and upload if it's an ADVERT.
Parses the raw packet hex, validates it is an ADVERT, checks for duplicates,
verifies signature, and triggers upload if valid.
Args:
raw_hex: Hex string representation of the raw packet.
"""
try:
# Parse packet to check if it's an ADVERT
byte_data = bytes.fromhex(raw_hex)
@@ -533,7 +589,14 @@ class MapUploaderService(BaseServicePlugin):
self.logger.error(f"Error processing packet: {e}", exc_info=True)
def _parse_advert(self, payload: bytes) -> Optional[Dict[str, Any]]:
"""Parse advert payload"""
"""Parse advert payload.
Args:
payload: Binary payload of the packet.
Returns:
Optional[Dict[str, Any]]: Parsed advert data dictionary or None if invalid.
"""
try:
if len(payload) < 101:
return None
@@ -602,7 +665,15 @@ class MapUploaderService(BaseServicePlugin):
return None
async def _verify_advert_signature(self, advert: Dict[str, Any], payload: bytes) -> bool:
"""Verify advert signature using ed25519"""
"""Verify advert signature using ed25519.
Args:
advert: The parsed advert dictionary containing the signature and public key.
payload: The full binary payload used to verify the signature.
Returns:
bool: True if signature is valid, False otherwise.
"""
if not CRYPTOGRAPHY_AVAILABLE:
self.logger.error("Cryptography library not available, cannot verify signatures")
return False # Fail verification if library not available (security)
@@ -640,8 +711,15 @@ class MapUploaderService(BaseServicePlugin):
self.logger.debug(f"Signature verification failed: {e}")
return False
async def _upload_to_map(self, advert: Dict[str, Any], raw_packet_hex: str):
"""Upload advert to map.meshcore.dev"""
async def _upload_to_map(self, advert: Dict[str, Any], raw_packet_hex: str) -> None:
"""Upload advert to map.meshcore.dev.
Signs the upload request and sends it via HTTP POST.
Args:
advert: Parsed advert data.
raw_packet_hex: Raw hex of the packet to report.
"""
if not self.http_session:
self.logger.error("HTTP session not available")
return
@@ -723,7 +801,14 @@ class MapUploaderService(BaseServicePlugin):
self.logger.error(f"Error uploading to map: {e}", exc_info=True)
def _sign_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Sign data using private key"""
"""Sign data using private key.
Args:
data: Dictionary of data to sign.
Returns:
Dict[str, Any]: Object containing original data (JSON string) and signature.
"""
# Convert data to JSON
json_str = json.dumps(data, separators=(',', ':'))
@@ -739,7 +824,18 @@ class MapUploaderService(BaseServicePlugin):
}
def _sign_hash(self, data_hash: bytes) -> str:
"""Sign a hash using ed25519 private key (orlp format)"""
"""Sign a hash using ed25519 private key (orlp format).
Args:
data_hash: The SHA256 hash of the data to sign.
Returns:
str: Hex string of the signature.
Raises:
ImportError: If PyNaCl is required but missing.
ValueError: If private key length is invalid.
"""
try:
# Convert private key to bytes
private_key_bytes = hex_to_bytes(self.private_key_hex)
+233 -55
View File
@@ -44,13 +44,21 @@ from .base_service import BaseServicePlugin
class PacketCaptureService(BaseServicePlugin):
"""Packet capture service using bot's meshcore connection"""
"""Packet capture service using bot's meshcore connection.
Captures packets from MeshCore network and publishes to MQTT.
Supports multiple MQTT brokers, auth tokens, and output to file.
"""
config_section = 'PacketCapture' # Explicit config section
description = "Captures packets from MeshCore network and publishes to MQTT"
def __init__(self, bot):
"""Initialize packet capture service"""
"""Initialize packet capture service.
Args:
bot: The bot instance.
"""
super().__init__(bot)
# Don't store meshcore here - it's None until bot connects
@@ -149,8 +157,12 @@ class PacketCaptureService(BaseServicePlugin):
self.logger.info("Packet capture service initialized")
def _load_config(self):
"""Load configuration from bot's config"""
def _load_config(self) -> None:
"""Load configuration from bot's config.
Loads settings for output file, MQTT brokers, auth tokens, and
other service options.
"""
config = self.bot.config
# Check if enabled
@@ -192,7 +204,14 @@ class PacketCaptureService(BaseServicePlugin):
# from the device if private_key_hex is None and meshcore_instance is available
def _parse_mqtt_brokers(self, config) -> List[Dict[str, Any]]:
"""Parse MQTT broker configuration (mqttN_* format)"""
"""Parse MQTT broker configuration (mqttN_* format).
Args:
config: ConfigParser object containing the configuration.
Returns:
List[Dict[str, Any]]: List of configured MQTT broker dictionaries.
"""
brokers = []
# Parse multiple brokers (mqtt1_*, mqtt2_*, etc.)
@@ -236,28 +255,68 @@ class PacketCaptureService(BaseServicePlugin):
return brokers
def get_config_bool(self, key: str, fallback: bool = False) -> bool:
"""Get boolean config value"""
"""Get boolean config value.
Args:
key: Config key to retrieve.
fallback: Default value if key is missing.
Returns:
bool: Config value or fallback.
"""
return self.bot.config.getboolean('PacketCapture', key, fallback=fallback)
def get_config_int(self, key: str, fallback: int = 0) -> int:
"""Get integer config value"""
"""Get integer config value.
Args:
key: Config key to retrieve.
fallback: Default value if key is missing.
Returns:
int: Config value or fallback.
"""
return self.bot.config.getint('PacketCapture', key, fallback=fallback)
def get_config_float(self, key: str, fallback: float = 0.0) -> float:
"""Get float config value"""
"""Get float config value.
Args:
key: Config key to retrieve.
fallback: Default value if key is missing.
Returns:
float: Config value or fallback.
"""
return self.bot.config.getfloat('PacketCapture', key, fallback=fallback)
def get_config_str(self, key: str, fallback: str = '') -> str:
"""Get string config value"""
"""Get string config value.
Args:
key: Config key to retrieve.
fallback: Default value if key is missing.
Returns:
str: Config value or fallback.
"""
return self.bot.config.get('PacketCapture', key, fallback=fallback)
@property
def meshcore(self):
"""Get meshcore connection from bot (always current)"""
"""Get meshcore connection from bot (always current).
Returns:
MeshCore: The meshcore instance from the bot.
"""
return self.bot.meshcore if self.bot else None
async def start(self):
"""Start the packet capture service"""
async def start(self) -> None:
"""Start the packet capture service.
Initializes output file, MQTT connections, and event handlers.
Waits for bot connection before starting.
"""
if not self.enabled:
self.logger.info("Packet capture service is disabled")
return
@@ -304,8 +363,11 @@ class PacketCaptureService(BaseServicePlugin):
self._running = True
self.logger.info(f"Packet capture service started (MQTT: {'connected' if self.mqtt_connected else 'not connected'})")
async def stop(self):
"""Stop the packet capture service"""
async def stop(self) -> None:
"""Stop the packet capture service.
Closes output file, disconnects MQTT, and stops background tasks.
"""
self.logger.info("Stopping packet capture service...")
self.should_exit = True
@@ -343,14 +405,20 @@ class PacketCaptureService(BaseServicePlugin):
self.logger.info(f"Packet capture service stopped. Total packets captured: {self.packet_count}")
def cleanup_event_subscriptions(self):
"""Clean up event subscriptions"""
def cleanup_event_subscriptions(self) -> None:
"""Clean up event subscriptions.
Clears local subscription tracking list.
"""
# Note: meshcore library handles subscription cleanup automatically
# This is mainly for tracking/logging
self.event_subscriptions = []
async def setup_event_handlers(self):
"""Setup event handlers for packet capture"""
async def setup_event_handlers(self) -> None:
"""Setup event handlers for packet capture.
Subscribes to RX_LOG_DATA and RAW_DATA events.
"""
if not self.meshcore:
return
@@ -373,8 +441,13 @@ class PacketCaptureService(BaseServicePlugin):
self.logger.info("Packet capture event handlers registered")
async def handle_rx_log_data(self, event, metadata=None):
"""Handle RX log data events (matches original script)"""
async def handle_rx_log_data(self, event: Any, metadata: Optional[Dict[str, Any]] = None) -> None:
"""Handle RX log data events (matches original script).
Args:
event: The RX log data event.
metadata: Optional metadata dictionary.
"""
try:
payload = event.payload
@@ -402,8 +475,13 @@ class PacketCaptureService(BaseServicePlugin):
except Exception as e:
self.logger.error(f"Error handling RX log data: {e}")
async def handle_raw_data(self, event, metadata=None):
"""Handle raw data events"""
async def handle_raw_data(self, event: Any, metadata: Optional[Dict[str, Any]] = None) -> None:
"""Handle raw data events.
Args:
event: The raw data event.
metadata: Optional metadata dictionary.
"""
try:
payload = event.payload
raw_data = payload.get('data', '')
@@ -427,8 +505,18 @@ class PacketCaptureService(BaseServicePlugin):
except Exception as e:
self.logger.error(f"Error handling raw data: {e}")
def _format_packet_data(self, raw_hex: str, packet_info: Dict, payload: Dict, metadata: Optional[Dict] = None) -> Dict[str, Any]:
"""Format packet data to match original script's format_packet_data exactly"""
def _format_packet_data(self, raw_hex: str, packet_info: Dict[str, Any], payload: Dict[str, Any], metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Format packet data to match original script's format_packet_data exactly.
Args:
raw_hex: Raw hex string of the packet.
packet_info: Decoded packet information.
payload: Payload dictionary from the event.
metadata: Optional metadata dictionary.
Returns:
Dict[str, Any]: Formatted packet dictionary.
"""
current_time = datetime.now()
timestamp = current_time.isoformat()
@@ -634,8 +722,16 @@ class PacketCaptureService(BaseServicePlugin):
return packet_data
async def process_packet(self, raw_hex: str, payload: Dict, metadata: Optional[Dict] = None):
"""Process a captured packet"""
async def process_packet(self, raw_hex: str, payload: Dict[str, Any], metadata: Optional[Dict[str, Any]] = None) -> None:
"""Process a captured packet.
Decodes the packet, formats it, writes to file, and publishes to MQTT.
Args:
raw_hex: Raw hex string of the packet.
payload: Payload dictionary from the event.
metadata: Optional metadata dictionary.
"""
try:
self.packet_count += 1
@@ -703,8 +799,16 @@ class PacketCaptureService(BaseServicePlugin):
except Exception as e:
self.logger.error(f"Error processing packet: {e}")
def decode_packet(self, raw_hex: str, payload: Dict) -> Optional[Dict]:
"""Decode a MeshCore packet - matches original packet_capture.py functionality"""
def decode_packet(self, raw_hex: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Decode a MeshCore packet - matches original packet_capture.py functionality.
Args:
raw_hex: Raw hex string of the packet.
payload: Payload dictionary from the event (unused in this method but kept for compatibility).
Returns:
Optional[Dict[str, Any]]: Decoded packet info, or None if decoding fails.
"""
try:
# Remove 0x prefix if present
if raw_hex.startswith('0x'):
@@ -805,7 +909,11 @@ class PacketCaptureService(BaseServicePlugin):
return None
def _get_bot_name(self) -> str:
"""Get bot name from device or config"""
"""Get bot name from device or config.
Returns:
str: The name of the bot/device.
"""
# Try to get name from device first
if self.meshcore and hasattr(self.meshcore, 'self_info'):
try:
@@ -828,7 +936,11 @@ class PacketCaptureService(BaseServicePlugin):
return bot_name
def _require_mqtt(self) -> bool:
"""Check if MQTT is available and required"""
"""Check if MQTT is available and required.
Returns:
bool: True if MQTT requirements are met, False otherwise.
"""
if mqtt is None:
self.logger.warning(
"MQTT support not available. Install paho-mqtt: "
@@ -837,8 +949,11 @@ class PacketCaptureService(BaseServicePlugin):
return False
return True
async def connect_mqtt_brokers(self):
"""Connect to MQTT brokers"""
async def connect_mqtt_brokers(self) -> None:
"""Connect to MQTT brokers.
Establish connections to all configured MQTT brokers.
"""
if not self._require_mqtt():
return
@@ -1101,8 +1216,16 @@ class PacketCaptureService(BaseServicePlugin):
else:
self.logger.warning("MQTT enabled but no brokers connected")
def _resolve_topic_template(self, template: str, packet_type: str = 'packet') -> str:
"""Resolve topic template with placeholders"""
def _resolve_topic_template(self, template: str, packet_type: str = 'packet') -> Optional[str]:
"""Resolve topic template with placeholders.
Args:
template: Topic template string.
packet_type: Type of packet ('packet' or 'status').
Returns:
Optional[str]: Resolved topic string, or None if template is empty.
"""
if not template:
return None
@@ -1137,8 +1260,15 @@ class PacketCaptureService(BaseServicePlugin):
return topic
async def publish_packet_mqtt(self, packet_info: Dict):
"""Publish packet to MQTT - returns metrics dict with 'attempted' and 'succeeded' counts"""
async def publish_packet_mqtt(self, packet_info: Dict[str, Any]) -> Dict[str, int]:
"""Publish packet to MQTT - returns metrics dict with 'attempted' and 'succeeded' counts.
Args:
packet_info: Formatted packet dictionary.
Returns:
Dict[str, int]: Dictionary with 'attempted' and 'succeeded' counts.
"""
# Always log when function is called (helps diagnose if it's not being invoked)
self.logger.debug(f"publish_packet_mqtt called (packet {self.packet_count}, {len(self.mqtt_clients)} clients)")
@@ -1202,8 +1332,12 @@ class PacketCaptureService(BaseServicePlugin):
return metrics
async def start_background_tasks(self):
"""Start background tasks"""
async def start_background_tasks(self) -> None:
"""Start background tasks.
Initializes scheduler for stats refresh, JWT renewal, health checks,
and MQTT reconnection monitor.
"""
# Stats refresh scheduler (matches original script)
if self.stats_status_enabled and self.stats_refresh_interval > 0:
self.stats_update_task = asyncio.create_task(self.stats_refresh_scheduler())
@@ -1224,8 +1358,11 @@ class PacketCaptureService(BaseServicePlugin):
task = asyncio.create_task(self.mqtt_reconnection_monitor())
self.background_tasks.append(task)
async def stats_refresh_scheduler(self):
"""Periodically refresh stats and publish them via MQTT (matches original script)"""
async def stats_refresh_scheduler(self) -> None:
"""Periodically refresh stats and publish them via MQTT (matches original script).
Fetches updated radio stats and triggers status publication.
"""
if self.stats_refresh_interval <= 0 or not self.stats_status_enabled:
return
@@ -1245,14 +1382,25 @@ class PacketCaptureService(BaseServicePlugin):
break
async def _wait_with_shutdown(self, timeout: float) -> bool:
"""Wait for specified time but return immediately if shutdown is requested"""
"""Wait for specified time but return immediately if shutdown is requested.
Args:
timeout: Time to wait in seconds.
Returns:
bool: True if shutdown requested, False if timeout completed.
"""
if self.should_exit:
return True
await asyncio.sleep(timeout)
return False
def _load_client_version(self) -> str:
"""Load client version (matches original script)"""
"""Load client version (matches original script).
Returns:
str: Version string (e.g., 'meshcore-bot/1.0.0-abcdef').
"""
try:
import os
import subprocess
@@ -1283,8 +1431,12 @@ class PacketCaptureService(BaseServicePlugin):
# Final fallback
return "meshcore-bot/unknown"
async def get_firmware_info(self):
"""Get firmware information from meshcore device (matches original script)"""
async def get_firmware_info(self) -> Dict[str, str]:
"""Get firmware information from meshcore device (matches original script).
Returns:
Dict[str, str]: Dictionary containing 'model' and 'version'.
"""
try:
# During shutdown, always use cached info - don't query the device
if self.should_exit:
@@ -1353,7 +1505,11 @@ class PacketCaptureService(BaseServicePlugin):
return {"model": "unknown", "version": "unknown"}
def stats_commands_available(self) -> bool:
"""Detect whether the connected meshcore build exposes stats commands (matches original script)"""
"""Detect whether the connected meshcore build exposes stats commands (matches original script).
Returns:
bool: True if stats commands are available.
"""
if not self.meshcore or not hasattr(self.meshcore, "commands"):
return False
@@ -1370,8 +1526,15 @@ class PacketCaptureService(BaseServicePlugin):
self.stats_supported = available
return available
async def refresh_stats(self, force: bool = False):
"""Fetch stats from the radio and cache them for status publishing (matches original script)"""
async def refresh_stats(self, force: bool = False) -> Optional[Dict[str, Any]]:
"""Fetch stats from the radio and cache them for status publishing (matches original script).
Args:
force: Force refresh even if cache is fresh.
Returns:
Optional[Dict[str, Any]]: Dictionary of stats or None if unavailable.
"""
if not self.stats_status_enabled:
if self.debug:
self.logger.debug("Stats refresh skipped: stats_status_enabled is False")
@@ -1436,8 +1599,13 @@ class PacketCaptureService(BaseServicePlugin):
return dict(self.latest_stats) if self.latest_stats else None
async def publish_status(self, status: str, refresh_stats: bool = True):
"""Publish status with additional information (matches original script exactly)"""
async def publish_status(self, status: str, refresh_stats: bool = True) -> None:
"""Publish status with additional information (matches original script exactly).
Args:
status: Status string (e.g., 'online', 'offline').
refresh_stats: Whether to refresh stats before publishing.
"""
firmware_info = await self.get_firmware_info()
# Get device name and public key
@@ -1545,8 +1713,11 @@ class PacketCaptureService(BaseServicePlugin):
except Exception as e:
self.logger.error(f"Error publishing status to MQTT: {e}")
async def jwt_renewal_scheduler(self):
"""Background task to check and renew JWT tokens"""
async def jwt_renewal_scheduler(self) -> None:
"""Background task to check and renew JWT tokens.
Periodically checks if JWT tokens need renewal.
"""
if self.jwt_renewal_interval <= 0:
return
@@ -1561,8 +1732,11 @@ class PacketCaptureService(BaseServicePlugin):
self.logger.error(f"Error in JWT renewal scheduler: {e}")
await asyncio.sleep(60)
async def health_check_loop(self):
"""Background task for health checks"""
async def health_check_loop(self) -> None:
"""Background task for health checks.
Monitors connection status and warns on failures.
"""
if self.health_check_interval <= 0:
return
@@ -1583,8 +1757,12 @@ class PacketCaptureService(BaseServicePlugin):
self.logger.error(f"Error in health check loop: {e}")
await asyncio.sleep(60)
async def mqtt_reconnection_monitor(self):
"""Proactive MQTT reconnection monitor - checks and reconnects disconnected brokers"""
async def mqtt_reconnection_monitor(self) -> None:
"""Proactive MQTT reconnection monitor - checks and reconnects disconnected brokers.
Periodically checks connectivity of all configured MQTT brokers and attempts
reconnection if disconnected.
"""
if not self.mqtt_enabled:
return
+109 -57
View File
@@ -38,23 +38,51 @@ except ImportError:
def hex_to_bytes(hex_str: str) -> bytes:
"""Convert hex string to bytes"""
"""Convert hex string to bytes.
Args:
hex_str: Hexadecimal string to convert.
Returns:
bytes: Converted bytes object.
"""
return bytes.fromhex(hex_str.replace('0x', '').replace(' ', ''))
def bytes_to_hex(data: bytes) -> str:
"""Convert bytes to hex string (lowercase)"""
"""Convert bytes to hex string (lowercase).
Args:
data: Bytes object to convert.
Returns:
str: Hexadecimal representation of the bytes (lowercase).
"""
return data.hex()
def base64url_encode(data: bytes) -> str:
"""Base64url encode (URL-safe base64 without padding)"""
"""Base64url encode (URL-safe base64 without padding).
Args:
data: Data to encode.
Returns:
str: URL-safe Base64 encoded string.
"""
b64 = base64.b64encode(data).decode('ascii')
return b64.replace('+', '-').replace('/', '_').replace('=', '')
def base64url_decode(data: str) -> bytes:
"""Base64url decode"""
"""Base64url decode.
Args:
data: URL-safe Base64 encoded string.
Returns:
bytes: Decoded bytes.
"""
b64 = data.replace('-', '+').replace('_', '/')
padding = 4 - (len(b64) % 4)
if padding != 4:
@@ -63,12 +91,27 @@ def base64url_decode(data: str) -> bytes:
def int_to_bytes_le(value: int, length: int) -> bytes:
"""Convert integer to little-endian bytes"""
"""Convert integer to little-endian bytes.
Args:
value: Integer value to convert.
length: Number of bytes to use.
Returns:
bytes: Little-endian byte representation.
"""
return value.to_bytes(length, byteorder='little')
def bytes_to_int_le(data: bytes) -> int:
"""Convert little-endian bytes to integer"""
"""Convert little-endian bytes to integer.
Args:
data: Bytes object to convert.
Returns:
int: Integer value.
"""
return int.from_bytes(data, byteorder='little')
@@ -77,20 +120,22 @@ L = 2**252 + 27742317777372353535851937790883648493
def ed25519_sign_with_expanded_key(message: bytes, scalar: bytes, prefix: bytes, public_key: bytes) -> bytes:
"""
Sign a message using Ed25519 with pre-expanded key (orlp format)
"""Sign a message using Ed25519 with pre-expanded key (orlp format).
This implements RFC 8032 Ed25519 signing with an already-expanded key.
This matches exactly how orlp/ed25519's ed25519_sign() works.
Args:
message: Message to sign
scalar: First 32 bytes of orlp private key (clamped scalar)
prefix: Last 32 bytes of orlp private key (prefix for nonce)
public_key: 32-byte public key
message: Message to sign.
scalar: First 32 bytes of orlp private key (clamped scalar).
prefix: Last 32 bytes of orlp private key (prefix for nonce).
public_key: 32-byte public key.
Returns:
64-byte signature (R || s)
bytes: 64-byte signature (R || s).
Raises:
ImportError: If PyNaCl is not available.
"""
if not PYNACL_AVAILABLE:
raise ImportError("PyNaCl is required for Ed25519 signing")
@@ -117,14 +162,13 @@ def ed25519_sign_with_expanded_key(message: bytes, scalar: bytes, prefix: bytes,
def read_private_key_file(key_file_path: str) -> Optional[str]:
"""
Read a private key from a file (64-byte hex format for orlp/ed25519)
"""Read a private key from a file (64-byte hex format for orlp/ed25519).
Args:
key_file_path: Path to the private key file
key_file_path: Path to the private key file.
Returns:
Private key as hex string (128 hex chars = 64 bytes), or None if invalid
Optional[str]: Private key as hex string (128 hex chars = 64 bytes), or None if invalid.
"""
if not os.path.exists(key_file_path):
return None
@@ -158,20 +202,23 @@ def read_private_key_file(key_file_path: str) -> Optional[str]:
async def _create_auth_token_with_device(
payload_dict: Dict[str, Any],
public_key_hex: str,
meshcore_instance,
meshcore_instance: Any,
chunk_size: int = 120
) -> str:
"""
Create auth token using on-device signing via meshcore.commands.sign()
"""Create auth token using on-device signing via meshcore.commands.sign().
Args:
payload_dict: Token payload as dictionary
public_key_hex: Public key in hex (for verification)
meshcore_instance: Connected MeshCore instance
chunk_size: Maximum chunk size for signing (device may have limits)
payload_dict: Token payload as dictionary.
public_key_hex: Public key in hex (for verification).
meshcore_instance: Connected MeshCore instance.
chunk_size: Maximum chunk size for signing (device may have limits).
Returns:
JWT-style token string (header.payload.signature)
str: JWT-style token string (header.payload.signature).
Raises:
ImportError: If meshcore package is missing.
Exception: If device is not connected or signing fails.
"""
try:
from meshcore import EventType
@@ -303,16 +350,19 @@ def _create_auth_token_python(
private_key_hex: str,
public_key_hex: str
) -> str:
"""
Create auth token using Python signing (PyNaCl)
"""Create auth token using Python signing (PyNaCl).
Args:
payload_dict: Token payload as dictionary
private_key_hex: 64-byte private key in hex (orlp format: scalar || prefix)
public_key_hex: 32-byte public key in hex
payload_dict: Token payload as dictionary.
private_key_hex: 64-byte private key in hex (orlp format: scalar || prefix).
public_key_hex: 32-byte public key in hex.
Returns:
JWT-style token string (header.payload.signature)
str: JWT-style token string (header.payload.signature).
Raises:
ImportError: If PyNaCl is required but missing.
ValueError: If key lengths are invalid.
"""
if not PYNACL_AVAILABLE:
raise ImportError("PyNaCl is required for Python signing. Install with: pip install pynacl")
@@ -378,15 +428,14 @@ def _create_auth_token_python(
return token
async def _fetch_private_key_from_device(meshcore_instance) -> Optional[str]:
"""
Attempt to export private key from device
async def _fetch_private_key_from_device(meshcore_instance: Any) -> Optional[str]:
"""Attempt to export private key from device.
Args:
meshcore_instance: Connected MeshCore instance
meshcore_instance: Connected MeshCore instance.
Returns:
Private key as hex string (128 hex chars), or None if not available
Optional[str]: Private key as hex string (128 hex chars), or None if not available.
"""
if not meshcore_instance or not meshcore_instance.is_connected:
return None
@@ -430,7 +479,7 @@ async def _fetch_private_key_from_device(meshcore_instance) -> Optional[str]:
async def create_auth_token_async(
meshcore_instance=None,
meshcore_instance: Optional[Any] = None,
public_key_hex: Optional[str] = None,
private_key_hex: Optional[str] = None,
iata: str = "LOC",
@@ -441,24 +490,28 @@ async def create_auth_token_async(
owner_email: Optional[str] = None,
use_device: bool = True
) -> str:
"""
Create a JWT-style authentication token for MQTT authentication
"""Create a JWT-style authentication token for MQTT authentication.
Supports on-device signing (preferred) with fallback to Python signing.
Args:
meshcore_instance: Optional connected MeshCore instance for on-device signing
public_key_hex: Public key in hex (required)
private_key_hex: Private key in hex (64 bytes = 128 hex chars, orlp format)
Required if meshcore_instance not available or device signing fails
iata: IATA code (default: "LOC")
timestamp: Unix timestamp for 'iat' claim (default: current time)
audience: Optional audience for token (e.g., MQTT broker hostname)
exp: Optional expiration time (Unix timestamp)
use_device: If True, try on-device signing first (default: True)
meshcore_instance: Optional connected MeshCore instance for on-device signing.
public_key_hex: Public key in hex (required).
private_key_hex: Private key in hex (64 bytes = 128 hex chars, orlp format).
Required if meshcore_instance not available or device signing fails.
iata: IATA code (default: "LOC").
timestamp: Unix timestamp for 'iat' claim (default: current time).
audience: Optional audience for token (e.g., MQTT broker hostname).
exp: Optional expiration time (Unix timestamp).
owner_public_key: Optional owner public key.
owner_email: Optional owner email.
use_device: If True, try on-device signing first (default: True).
Returns:
JWT-style token string (header.payload.signature)
str: JWT-style token string (header.payload.signature).
Raises:
ValueError: If public_key_hex is missing or private key is missing for Python signing.
"""
if timestamp is None:
timestamp = int(time.time())
@@ -564,18 +617,17 @@ def create_auth_token(
timestamp: Optional[int] = None,
audience: Optional[str] = None
) -> str:
"""
Synchronous version of create_auth_token (Python signing only)
"""Synchronous version of create_auth_token (Python signing only).
Args:
private_key_hex: Private key in hex (64 bytes = 128 hex chars, orlp format)
public_key_hex: Public key in hex (32 bytes = 64 hex chars)
iata: IATA code (default: "LOC")
timestamp: Unix timestamp (default: current time)
audience: Optional audience for token
private_key_hex: Private key in hex (64 bytes = 128 hex chars, orlp format).
public_key_hex: Public key in hex (32 bytes = 64 hex chars).
iata: IATA code (default: "LOC").
timestamp: Unix timestamp (default: current time).
audience: Optional audience for token.
Returns:
JWT-style token string (header.payload.signature)
str: JWT-style token string (header.payload.signature).
"""
if timestamp is None:
timestamp = int(time.time())
+167 -58
View File
@@ -31,13 +31,21 @@ from .base_service import BaseServicePlugin
class WeatherService(BaseServicePlugin):
"""Weather service providing scheduled forecasts and alert monitoring"""
"""Weather service providing scheduled forecasts and alert monitoring.
Manages daily weather forecasts, polls for NOAA weather alerts, and
monitors lightning strikes via MQTT (Blitzortung).
"""
config_section = 'Weather_Service'
description = "Scheduled weather forecasts and alert monitoring"
def __init__(self, bot):
"""Initialize weather service"""
def __init__(self, bot: Any):
"""Initialize weather service.
Args:
bot: The bot instance.
"""
super().__init__(bot)
# Configuration
@@ -106,7 +114,11 @@ class WeatherService(BaseServicePlugin):
self.logger.info(f"Weather service initialized: position=({self.my_position_lat}, {self.my_position_lon}), alarm={self.weather_alarm_time}")
def _create_retry_session(self) -> requests.Session:
"""Create a requests session with retry logic for API calls"""
"""Create a requests session with retry logic for API calls.
Returns:
requests.Session: Configured session with retry adapter.
"""
session = requests.Session()
retry_strategy = Retry(
total=2,
@@ -125,13 +137,13 @@ class WeatherService(BaseServicePlugin):
return session
def _get_sunrise_sunset_time(self, event: str) -> Optional[datetime]:
"""Get sunrise or sunset time for configured position
"""Get sunrise or sunset time for configured position.
Args:
event: 'sunrise' or 'sunset'
event: 'sunrise' or 'sunset'.
Returns:
datetime object with the next sunrise/sunset time, or None on error
Optional[datetime]: Datetime object with the next sunrise/sunset time, or None on error.
"""
try:
obs = ephem.Observer()
@@ -154,8 +166,11 @@ class WeatherService(BaseServicePlugin):
self.logger.error(f"Error calculating {event}: {e}")
return None
async def start(self):
"""Start the weather service"""
async def start(self) -> None:
"""Start the weather service.
Initializes scheduled tasks for forecasts, alert polling, and lightning detection.
"""
if not self.enabled:
self.logger.info("Weather service is disabled, not starting")
return
@@ -186,8 +201,11 @@ class WeatherService(BaseServicePlugin):
self.logger.info("Weather service started")
async def stop(self):
"""Stop the weather service"""
async def stop(self) -> None:
"""Stop the weather service.
cancels all background tasks and closes connections.
"""
self._running = False
self.logger.info("Stopping weather service")
@@ -235,8 +253,11 @@ class WeatherService(BaseServicePlugin):
self.logger.info("Weather service stopped")
def _setup_daily_forecast(self):
"""Setup daily weather forecast schedule for fixed times"""
def _setup_daily_forecast(self) -> None:
"""Setup daily weather forecast schedule for fixed times.
Configures the schedule library to trigger _send_daily_forecast at the configured time.
"""
try:
# Parse time (format: "HH:MM" or "H:MM")
if ':' in self.weather_alarm_time:
@@ -252,8 +273,11 @@ class WeatherService(BaseServicePlugin):
except Exception as e:
self.logger.error(f"Error setting up daily forecast schedule: {e}")
async def _sunrise_sunset_forecast_loop(self):
"""Background task for sunrise/sunset-based forecasts"""
async def _sunrise_sunset_forecast_loop(self) -> None:
"""Background task for sunrise/sunset-based forecasts.
Calculates daily sunrise/sunset times and schedules the forecast accordingly.
"""
event_type = self.weather_alarm_time.lower()
self.logger.info(f"Starting {event_type}-based forecast loop")
@@ -301,8 +325,11 @@ class WeatherService(BaseServicePlugin):
self.logger.error(f"Error in {event_type} forecast loop: {e}")
await asyncio.sleep(3600) # Wait 1 hour on error
def _send_daily_forecast(self):
"""Send daily weather forecast (called by schedule library)"""
def _send_daily_forecast(self) -> None:
"""Send daily weather forecast (called by schedule library).
Wrapper to run the async forecast sender from the synchronous schedule job.
"""
if not self._running:
return
@@ -317,8 +344,12 @@ class WeatherService(BaseServicePlugin):
loop.run_until_complete(self._send_daily_forecast_async())
async def _send_daily_forecast_async(self):
"""Send daily weather forecast (async implementation)"""
async def _send_daily_forecast_async(self) -> None:
"""Send daily weather forecast (async implementation).
Fetches the forecast and sends it to the configured channel.
Uses Open-Meteo for weather data and manages its own error logging.
"""
try:
# Get weather forecast
forecast_text = await self._get_weather_forecast()
@@ -336,7 +367,11 @@ class WeatherService(BaseServicePlugin):
self.logger.error(f"Error sending daily weather forecast: {e}")
async def _get_weather_forecast(self) -> str:
"""Get weather forecast for configured position using Open-Meteo API"""
"""Get weather forecast for configured position using Open-Meteo API.
Returns:
str: Formatted forecast string or error message.
"""
try:
# Open-Meteo API endpoint
api_url = "https://api.open-meteo.com/v1/forecast"
@@ -451,7 +486,14 @@ class WeatherService(BaseServicePlugin):
return "Error fetching weather data"
def _degrees_to_direction(self, degrees: float) -> str:
"""Convert wind direction in degrees to compass direction"""
"""Convert wind direction in degrees to compass direction.
Args:
degrees: Wind direction in degrees (0-360).
Returns:
str: Compass direction (e.g., 'N', 'NE', 'SW').
"""
if degrees is None:
return ""
@@ -461,7 +503,14 @@ class WeatherService(BaseServicePlugin):
return directions[index]
def _get_weather_description(self, code: int) -> str:
"""Get weather description from WMO weather code"""
"""Get weather description from WMO weather code.
Args:
code: WMO weather code integer.
Returns:
str: Human-readable weather description.
"""
# WMO Weather interpretation codes (WW)
codes = {
0: "Clear", 1: "Mostly Clear", 2: "Partly Cloudy", 3: "Overcast",
@@ -478,7 +527,14 @@ class WeatherService(BaseServicePlugin):
return codes.get(code, "Unknown")
def _get_weather_emoji(self, code: int) -> str:
"""Get weather emoji from WMO weather code"""
"""Get weather emoji from WMO weather code.
Args:
code: WMO weather code integer.
Returns:
str: Emoji character representing the weather.
"""
if code == 0:
return "☀️"
elif code in [1, 2]:
@@ -496,8 +552,11 @@ class WeatherService(BaseServicePlugin):
else:
return "🌤️"
async def _poll_weather_alerts_loop(self):
"""Background task to poll for weather alerts"""
async def _poll_weather_alerts_loop(self) -> None:
"""Background task to poll for weather alerts.
Runs periodically based on configured interval.
"""
self.logger.info(f"Starting weather alerts polling (interval: {self.poll_weather_alerts_interval}s)")
while self._running:
@@ -510,8 +569,8 @@ class WeatherService(BaseServicePlugin):
self.logger.error(f"Error in weather alerts polling loop: {e}")
await asyncio.sleep(60) # Wait 1 minute on error before retrying
async def _check_weather_alerts(self):
"""Check for new weather alerts (US-only via NOAA API)
async def _check_weather_alerts(self) -> None:
"""Check for new weather alerts (US-only via NOAA API).
Note: Open-Meteo doesn't provide weather alerts, so we use NOAA which is US-only.
For international locations, alerts will not be available.
@@ -626,8 +685,11 @@ class WeatherService(BaseServicePlugin):
except Exception as e:
self.logger.error(f"Error checking weather alerts: {e}")
async def _connect_blitzortung_mqtt(self):
"""Connect to Blitzortung MQTT broker and subscribe to lightning data"""
async def _connect_blitzortung_mqtt(self) -> None:
"""Connect to Blitzortung MQTT broker and subscribe to lightning data.
Maintains a connection to the MQTT broker for real-time lightning strikes.
"""
if not self.blitz_area or not MQTT_AVAILABLE:
return
@@ -709,8 +771,14 @@ class WeatherService(BaseServicePlugin):
self.logger.info("Reconnecting to Blitzortung MQTT in 30 seconds...")
await asyncio.sleep(30)
async def _handle_lightning_strike(self, blitz_data: Dict[str, Any]):
"""Handle a single lightning strike from MQTT"""
async def _handle_lightning_strike(self, blitz_data: Dict[str, Any]) -> None:
"""Handle a single lightning strike from MQTT.
Calculates distance and adds to buffer if within range.
Args:
blitz_data: Dictionary containing lightning strike data.
"""
lat = blitz_data.get('lat')
lon = blitz_data.get('lon')
@@ -737,10 +805,16 @@ class WeatherService(BaseServicePlugin):
})
def _calculate_heading_and_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> tuple:
"""Calculate heading and distance between two points (same as original implementation)
"""Calculate heading and distance between two points (same as original implementation).
Args:
lat1: Latitude of point 1.
lon1: Longitude of point 1.
lat2: Latitude of point 2.
lon2: Longitude of point 2.
Returns:
(heading_degrees, distance_km)
tuple: (heading_degrees, distance_km)
"""
# Convert to radians
lat1_rad = math.radians(lat1)
@@ -762,8 +836,11 @@ class WeatherService(BaseServicePlugin):
return (int(heading_deg), distance_km)
async def _poll_lightning_loop(self):
"""Background task to aggregate and report lightning strikes"""
async def _poll_lightning_loop(self) -> None:
"""Background task to aggregate and report lightning strikes.
Periodically processes the lightning buffer and sends alerts.
"""
self.logger.info(f"Starting lightning aggregation (interval: {self.blitz_collection_interval}s)")
while self._running:
@@ -776,8 +853,11 @@ class WeatherService(BaseServicePlugin):
self.logger.error(f"Error in lightning aggregation loop: {e}")
await asyncio.sleep(60) # Wait 1 minute on error before retrying
async def _process_lightning_buffer(self):
"""Process buffered lightning strikes and send alerts if threshold met"""
async def _process_lightning_buffer(self) -> None:
"""Process buffered lightning strikes and send alerts if threshold met.
Groups strikes by location bucket and sends alerts if count exceeds threshold.
"""
if not self.blitz_buffer:
return
@@ -832,7 +912,14 @@ class WeatherService(BaseServicePlugin):
self.seen_blitz_keys = set(list(self.seen_blitz_keys)[-1000:])
def _heading_to_compass(self, heading: int) -> str:
"""Convert heading in degrees to compass direction name"""
"""Convert heading in degrees to compass direction name.
Args:
heading: Heading in degrees.
Returns:
str: Compass direction abbreviation (e.g., 'N', 'NW').
"""
compass_points = [
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
@@ -841,7 +928,15 @@ class WeatherService(BaseServicePlugin):
return compass_points[index]
async def _geocode_location(self, lat: float, lon: float) -> Optional[str]:
"""Geocode coordinates to location name (optional, may return None)"""
"""Geocode coordinates to location name (optional, may return None).
Args:
lat: Latitude.
lon: Longitude.
Returns:
Optional[str]: City/town name or None if lookup fails.
"""
try:
# Use reverse geocoding if available in utils
from ..utils import rate_limited_nominatim_reverse_sync
@@ -855,15 +950,15 @@ class WeatherService(BaseServicePlugin):
pass
return None
def _parse_alert_entry(self, entry, alert_id: str) -> Optional[Dict[str, Any]]:
"""Parse alert XML entry and extract full metadata (same logic as wx_command)
def _parse_alert_entry(self, entry: Any, alert_id: str) -> Optional[Dict[str, Any]]:
"""Parse alert XML entry and extract full metadata (same logic as wx_command).
Args:
entry: XML DOM entry element
alert_id: Alert ID string
entry: XML DOM entry element.
alert_id: Alert ID string.
Returns:
Alert dict with event, event_type, severity, expires, office, etc., or None on error
Optional[Dict[str, Any]]: Alert dict with event, event_type, severity, expires, office, etc., or None on error.
"""
try:
# Extract title
@@ -1101,14 +1196,14 @@ class WeatherService(BaseServicePlugin):
return None
async def _format_alert_compact(self, alert: Dict[str, Any], include_details: bool = True) -> str:
"""Format a single alert compactly (same as wx_command)
"""Format a single alert compactly (same as wx_command).
Args:
alert: Alert dict with event, event_type, severity, expires, office, etc.
include_details: If True, include expiration time and office
include_details: If True, include expiration time and office.
Returns:
Formatted alert string
str: Formatted alert string.
"""
event = alert.get('event', '')
event_type = alert.get('event_type', '')
@@ -1240,7 +1335,14 @@ class WeatherService(BaseServicePlugin):
return f"{severity_emoji}{event} {event_type_abbrev}" if event else f"{severity_emoji}{event_type_abbrev}"
def _compact_time(self, time_str: str) -> str:
"""Compact time format (same as wx_command)"""
"""Compact time format (same as wx_command).
Args:
time_str: Time string to format.
Returns:
str: Compact formatted time string.
"""
if not time_str:
return time_str
@@ -1289,7 +1391,14 @@ class WeatherService(BaseServicePlugin):
return time_str
def _abbreviate_city_name(self, city: str) -> str:
"""Abbreviate city names for compact display (same as wx_command)"""
"""Abbreviate city names for compact display (same as wx_command).
Args:
city: Full city name.
Returns:
str: Abbreviated city name.
"""
if not city:
return city
@@ -1332,13 +1441,13 @@ class WeatherService(BaseServicePlugin):
return city[:4].upper() if len(city) >= 4 else city.upper()
def _parse_iso_time(self, time_str: str) -> Optional[float]:
"""Parse ISO 8601 timestamp to Unix timestamp
"""Parse ISO 8601 timestamp to Unix timestamp.
Args:
time_str: ISO 8601 time string (e.g., "2025-12-16T15:12:00-08:00" or "2025-12-16T15:12:00Z")
time_str: ISO 8601 time string (e.g., "2025-12-16T15:12:00-08:00" or "2025-12-16T15:12:00Z").
Returns:
Unix timestamp (seconds since epoch), or None if parsing fails
Optional[float]: Unix timestamp (seconds since epoch), or None if parsing fails.
"""
if not time_str:
return None
@@ -1351,13 +1460,13 @@ class WeatherService(BaseServicePlugin):
return None
def _parse_alert_time(self, time_str: str) -> Optional[float]:
"""Parse alert effective/issued time string to Unix timestamp
"""Parse alert effective/issued time string to Unix timestamp.
Args:
time_str: Time string from alert (e.g., "December 16 at 3:12PM PST" or ISO format)
time_str: Time string from alert (e.g., "December 16 at 3:12PM PST" or ISO format).
Returns:
Unix timestamp (seconds since epoch), or None if parsing fails
Optional[float]: Unix timestamp (seconds since epoch), or None if parsing fails.
"""
if not time_str:
return None
@@ -1416,13 +1525,13 @@ class WeatherService(BaseServicePlugin):
return None
async def _shorten_url(self, url: str) -> str:
"""Shorten URL using is.gd service
"""Shorten URL using is.gd service.
Args:
url: Full URL to shorten
url: Full URL to shorten.
Returns:
Shortened URL string, or empty string on error
str: Shortened URL string, or empty string on error.
"""
if not url:
return ""
+137 -153
View File
@@ -15,15 +15,14 @@ from typing import Optional, Tuple, Dict, Union, List, Any
def abbreviate_location(location: str, max_length: int = 20) -> str:
"""
Abbreviate a location string to fit within character limits.
"""Abbreviate a location string to fit within character limits.
Args:
location: The location string to abbreviate
max_length: Maximum length for the abbreviated string
location: The location string to abbreviate.
max_length: Maximum length for the abbreviated string (default: 20).
Returns:
Abbreviated location string
str: Abbreviated location string.
"""
if not location:
return location
@@ -118,16 +117,15 @@ def abbreviate_location(location: str, max_length: int = 20) -> str:
def truncate_string(text: str, max_length: int, ellipsis: str = '...') -> str:
"""
Truncate a string to a maximum length with ellipsis.
"""Truncate a string to a maximum length with ellipsis.
Args:
text: The string to truncate
max_length: Maximum length including ellipsis
ellipsis: String to append when truncating
text: The string to truncate.
max_length: Maximum length including ellipsis.
ellipsis: String to append when truncating (default: '...').
Returns:
Truncated string
str: Truncated string.
"""
if not text or len(text) <= max_length:
return text
@@ -137,17 +135,16 @@ def truncate_string(text: str, max_length: int, ellipsis: str = '...') -> str:
def format_location_for_display(city: Optional[str], state: Optional[str] = None,
country: Optional[str] = None, max_length: int = 20) -> Optional[str]:
"""
Format location data for display with intelligent abbreviation.
"""Format location data for display with intelligent abbreviation.
Args:
city: City name (may include neighborhood/district)
state: State/province name
country: Country name
max_length: Maximum length for the formatted location
city: City name (may include neighborhood/district).
state: State/province name (optional).
country: Country name (optional).
max_length: Maximum length for the formatted location (default: 20).
Returns:
Formatted location string or None if no location data
Optional[str]: Formatted location string or None if no city provided.
"""
if not city:
return None
@@ -164,18 +161,18 @@ def format_location_for_display(city: Optional[str], state: Optional[str] = None
return abbreviate_location(full_location, max_length)
def get_major_city_queries(city: str, state_abbr: Optional[str] = None) -> list:
"""
Get prioritized geocoding queries for major cities that have multiple locations.
def get_major_city_queries(city: str, state_abbr: Optional[str] = None) -> List[str]:
"""Get prioritized geocoding queries for major cities that have multiple locations.
This helps ensure that common city names resolve to the most likely major city
rather than a small town with the same name.
Args:
city: City name (normalized, lowercase)
state_abbr: Optional state abbreviation (e.g., "CA", "NY")
city: City name (normalized, lowercase).
state_abbr: Optional state abbreviation (e.g., "CA", "NY").
Returns:
List of geocoding query strings in priority order
List[str]: List of geocoding query strings in priority order.
"""
city_lower = city.lower().strip()
@@ -264,18 +261,18 @@ def get_major_city_queries(city: str, state_abbr: Optional[str] = None) -> list:
def calculate_packet_hash(raw_hex: str, payload_type: int = None) -> str:
"""
Calculate hash for packet identification - based on packet.cpp
"""Calculate hash for packet identification - based on packet.cpp.
Packet hashes are unique to the originally sent message, allowing
identification of the same message arriving via different paths.
Args:
raw_hex: Raw packet data as hex string
payload_type: Optional payload type as integer (if None, extracted from header)
Must be numeric value (0-15), not enum or string
raw_hex: Raw packet data as hex string.
payload_type: Optional payload type as integer (if None, extracted from header).
Must be numeric value (0-15).
Returns:
16-character hex string (8 bytes) in uppercase, or "0000000000000000" on error
str: 16-character hex string (8 bytes) in uppercase, or "0000000000000000" on error.
"""
try:
# Parse the packet to extract payload type and payload data
@@ -344,17 +341,16 @@ def calculate_packet_hash(raw_hex: str, payload_type: int = None) -> str:
def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""
Calculate haversine distance between two points in kilometers.
"""Calculate haversine distance between two points in kilometers.
Args:
lat1: Latitude of first point in degrees
lon1: Longitude of first point in degrees
lat2: Latitude of second point in degrees
lon2: Longitude of second point in degrees
lat1: Latitude of first point in degrees.
lon1: Longitude of first point in degrees.
lat2: Latitude of second point in degrees.
lon2: Longitude of second point in degrees.
Returns:
Distance in kilometers
float: Distance in kilometers.
"""
import math
@@ -375,32 +371,30 @@ def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl
return earth_radius * c
def get_nominatim_geocoder(user_agent: str = "meshcore-bot", timeout: int = 10):
"""
Get a Nominatim geocoder instance with proper User-Agent.
def get_nominatim_geocoder(user_agent: str = "meshcore-bot", timeout: int = 10) -> Any:
"""Get a Nominatim geocoder instance with proper User-Agent.
Args:
user_agent: User-Agent string for Nominatim (required by their policy)
timeout: Request timeout in seconds
user_agent: User-Agent string for Nominatim (required by their policy).
timeout: Request timeout in seconds.
Returns:
Nominatim geocoder instance
Any: Nominatim geocoder instance (from geopy).
"""
from geopy.geocoders import Nominatim
return Nominatim(user_agent=user_agent, timeout=timeout)
async def rate_limited_nominatim_geocode(bot, query: str, timeout: int = 10):
"""
Perform rate-limited Nominatim geocoding (forward geocoding).
async def rate_limited_nominatim_geocode(bot: Any, query: str, timeout: int = 10) -> Optional[Any]:
"""Perform rate-limited Nominatim geocoding (forward geocoding).
Args:
bot: Bot instance (must have nominatim_rate_limiter attribute)
query: Location query string
timeout: Request timeout in seconds
bot: Bot instance (must have nominatim_rate_limiter attribute).
query: Location query string.
timeout: Request timeout in seconds.
Returns:
Geocoding result or None
Optional[Any]: Geocoding result or None if failed/timed out.
"""
if not hasattr(bot, 'nominatim_rate_limiter'):
# Fallback if rate limiter not initialized
@@ -420,17 +414,16 @@ async def rate_limited_nominatim_geocode(bot, query: str, timeout: int = 10):
return result
async def rate_limited_nominatim_reverse(bot, coordinates: str, timeout: int = 10):
"""
Perform rate-limited Nominatim reverse geocoding.
async def rate_limited_nominatim_reverse(bot: Any, coordinates: str, timeout: int = 10) -> Optional[Any]:
"""Perform rate-limited Nominatim reverse geocoding.
Args:
bot: Bot instance (must have nominatim_rate_limiter attribute)
coordinates: Coordinates string in format "lat, lon"
timeout: Request timeout in seconds
bot: Bot instance (must have nominatim_rate_limiter attribute).
coordinates: Coordinates string in format "lat, lon".
timeout: Request timeout in seconds.
Returns:
Reverse geocoding result or None
Optional[Any]: Reverse geocoding result or None if failed/timed out.
"""
if not hasattr(bot, 'nominatim_rate_limiter'):
# Fallback if rate limiter not initialized
@@ -450,17 +443,16 @@ async def rate_limited_nominatim_reverse(bot, coordinates: str, timeout: int = 1
return result
def rate_limited_nominatim_geocode_sync(bot, query: str, timeout: int = 10):
"""
Perform rate-limited Nominatim geocoding (synchronous version).
def rate_limited_nominatim_geocode_sync(bot: Any, query: str, timeout: int = 10) -> Optional[Any]:
"""Perform rate-limited Nominatim geocoding (synchronous version).
Args:
bot: Bot instance (must have nominatim_rate_limiter attribute)
query: Location query string
timeout: Request timeout in seconds
bot: Bot instance (must have nominatim_rate_limiter attribute).
query: Location query string.
timeout: Request timeout in seconds.
Returns:
Geocoding result or None
Optional[Any]: Geocoding result or None if failed/timed out.
"""
if not hasattr(bot, 'nominatim_rate_limiter'):
# Fallback if rate limiter not initialized
@@ -480,17 +472,16 @@ def rate_limited_nominatim_geocode_sync(bot, query: str, timeout: int = 10):
return result
def rate_limited_nominatim_reverse_sync(bot, coordinates: str, timeout: int = 10):
"""
Perform rate-limited Nominatim reverse geocoding (synchronous version).
def rate_limited_nominatim_reverse_sync(bot: Any, coordinates: str, timeout: int = 10) -> Optional[Any]:
"""Perform rate-limited Nominatim reverse geocoding (synchronous version).
Args:
bot: Bot instance (must have nominatim_rate_limiter attribute)
coordinates: Coordinates string in format "lat, lon"
timeout: Request timeout in seconds
bot: Bot instance (must have nominatim_rate_limiter attribute).
coordinates: Coordinates string in format "lat, lon".
timeout: Request timeout in seconds.
Returns:
Reverse geocoding result or None
Optional[Any]: Reverse geocoding result or None if failed/timed out.
"""
if not hasattr(bot, 'nominatim_rate_limiter'):
# Fallback if rate limiter not initialized
@@ -510,19 +501,19 @@ def rate_limited_nominatim_reverse_sync(bot, coordinates: str, timeout: int = 10
return result
async def geocode_zipcode(bot, zipcode: str, default_country: str = None, timeout: int = 10) -> Tuple[Optional[float], Optional[float]]:
"""
Shared function to geocode a ZIP code to lat/lon coordinates.
async def geocode_zipcode(bot: Any, zipcode: str, default_country: 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.
Args:
bot: Bot instance (must have db_manager and nominatim_rate_limiter)
zipcode: ZIP code string
default_country: Default country code (e.g., "US"). If None, reads from bot.config
timeout: Request timeout in seconds
bot: Bot instance (must have db_manager and nominatim_rate_limiter).
zipcode: ZIP code string.
default_country: Default country code (e.g., "US"). If None, reads from bot.config.
timeout: Request timeout in seconds.
Returns:
Tuple of (latitude, longitude) or (None, None) if not found
Tuple[Optional[float], Optional[float]]: Tuple of (latitude, longitude) or (None, None) if not found.
"""
try:
# Get default country from config if not provided
@@ -548,18 +539,17 @@ async def geocode_zipcode(bot, zipcode: str, default_country: str = None, timeou
return None, None
def geocode_zipcode_sync(bot, zipcode: str, default_country: str = None, timeout: int = 10) -> Tuple[Optional[float], Optional[float]]:
"""
Synchronous version of geocode_zipcode.
def geocode_zipcode_sync(bot: Any, zipcode: str, default_country: str = None, timeout: int = 10) -> Tuple[Optional[float], Optional[float]]:
"""Synchronous version of geocode_zipcode.
Args:
bot: Bot instance (must have db_manager and nominatim_rate_limiter)
zipcode: ZIP code string
default_country: Default country code (e.g., "US"). If None, reads from bot.config
timeout: Request timeout in seconds
bot: Bot instance (must have db_manager and nominatim_rate_limiter).
zipcode: ZIP code string.
default_country: Default country code (e.g., "US"). If None, reads from bot.config.
timeout: Request timeout in seconds.
Returns:
Tuple of (latitude, longitude) or (None, None) if not found
Tuple[Optional[float], Optional[float]]: Tuple of (latitude, longitude) or (None, None) if not found.
"""
try:
# Get default country from config if not provided
@@ -585,25 +575,26 @@ def geocode_zipcode_sync(bot, zipcode: str, default_country: str = None, timeout
return None, None
async def geocode_city(bot, city: str, default_state: str = None,
async def geocode_city(bot: Any, city: str, default_state: str = None,
default_country: 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.
"""Shared function to geocode a city name to lat/lon coordinates.
Uses intelligent fallback logic with major city prioritization.
Args:
bot: Bot instance (must have db_manager and nominatim_rate_limiter)
city: City name (may include state/country, e.g., "Seattle, WA" or "Paris, France")
default_state: Default state abbreviation (e.g., "WA"). If None, reads from bot.config
default_country: Default country code (e.g., "US"). If None, reads from bot.config
include_address_info: If True, also return address info via reverse geocoding
timeout: Request timeout in seconds
bot: Bot instance (must have db_manager and nominatim_rate_limiter).
city: City name (may include state/country, e.g., "Seattle, WA" or "Paris, France").
default_state: Default state abbreviation (e.g., "WA"). If None, reads from bot.config.
default_country: Default country code (e.g., "US"). If None, reads from bot.config.
include_address_info: If True, also return address info via reverse geocoding.
timeout: Request timeout in seconds.
Returns:
Tuple of (latitude, longitude, address_info_dict) or (None, None, None) if not found
address_info_dict is None if include_address_info is False
Tuple[Optional[float], Optional[float], Optional[Dict]]:
Tuple of (latitude, longitude, address_info_dict) or (None, None, None) if not found.
address_info_dict is None if include_address_info is False.
"""
try:
# Get defaults from config if not provided
@@ -778,24 +769,24 @@ async def geocode_city(bot, city: str, default_state: str = None,
return None, None, None
def geocode_city_sync(bot, city: str, default_state: str = None,
def geocode_city_sync(bot: Any, city: str, default_state: str = None,
default_country: str = None,
include_address_info: bool = False,
timeout: int = 10) -> Tuple[Optional[float], Optional[float], Optional[Dict]]:
"""
Synchronous version of geocode_city.
"""Synchronous version of geocode_city.
Args:
bot: Bot instance (must have db_manager and nominatim_rate_limiter)
city: City name (may include state/country, e.g., "Seattle, WA" or "Paris, France")
default_state: Default state abbreviation (e.g., "WA"). If None, reads from bot.config
default_country: Default country code (e.g., "US"). If None, reads from bot.config
include_address_info: If True, also return address info via reverse geocoding
timeout: Request timeout in seconds
bot: Bot instance (must have db_manager and nominatim_rate_limiter).
city: City name (may include state/country, e.g., "Seattle, WA" or "Paris, France").
default_state: Default state abbreviation (e.g., "WA"). If None, reads from bot.config.
default_country: Default country code (e.g., "US"). If None, reads from bot.config.
include_address_info: If True, also return address info via reverse geocoding.
timeout: Request timeout in seconds.
Returns:
Tuple of (latitude, longitude, address_info_dict) or (None, None, None) if not found
address_info_dict is None if include_address_info is False
Tuple[Optional[float], Optional[float], Optional[Dict]]:
Tuple of (latitude, longitude, address_info_dict) or (None, None, None) if not found.
address_info_dict is None if include_address_info is False.
"""
try:
# Get defaults from config if not provided
@@ -971,18 +962,17 @@ def geocode_city_sync(bot, city: str, default_state: str = None,
def resolve_path(file_path: Union[str, Path], base_dir: Union[str, Path] = '.') -> str:
"""
Resolve a file path relative to a base directory.
"""Resolve a file path relative to a base directory.
If the path is absolute, it is resolved and returned as-is.
If the path is relative, it is resolved relative to the base directory.
Args:
file_path: Path to resolve (can be string or Path object)
base_dir: Base directory for resolving relative paths (default: current directory)
file_path: Path to resolve (can be string or Path object).
base_dir: Base directory for resolving relative paths (default: current directory).
Returns:
Resolved absolute path as a string
str: Resolved absolute path as a string.
Examples:
>>> resolve_path('data.db', '/opt/bot')
@@ -1000,19 +990,18 @@ def resolve_path(file_path: Union[str, Path], base_dir: Union[str, Path] = '.')
def check_internet_connectivity(host: str = "8.8.8.8", port: int = 53, timeout: float = 3.0) -> bool:
"""
Check if internet connectivity is available by attempting to connect to a reliable host.
"""Check if internet connectivity is available by attempting to connect to a reliable host.
First tries a lightweight DNS port check (faster, doesn't require DNS resolution).
If that fails (e.g., DNS port is blocked), falls back to an HTTP request check.
Args:
host: Host to connect to (default: 8.8.8.8, Google's public DNS)
port: Port to connect to (default: 53, DNS port)
timeout: Connection timeout in seconds (default: 3.0)
host: Host to connect to (default: 8.8.8.8, Google's public DNS).
port: Port to connect to (default: 53, DNS port).
timeout: Connection timeout in seconds (default: 3.0).
Returns:
True if connection successful, False otherwise
bool: True if connection successful, False otherwise.
"""
# First try: DNS port check (fastest, works if DNS port is open)
try:
@@ -1045,19 +1034,18 @@ def check_internet_connectivity(host: str = "8.8.8.8", port: int = 53, timeout:
async def check_internet_connectivity_async(host: str = "8.8.8.8", port: int = 53, timeout: float = 3.0) -> bool:
"""
Async version of check_internet_connectivity.
"""Async version of check_internet_connectivity.
First tries a lightweight DNS port check (faster, doesn't require DNS resolution).
If that fails (e.g., DNS port is blocked), falls back to an HTTP request check.
Args:
host: Host to connect to (default: 8.8.8.8, Google's public DNS)
port: Port to connect to (default: 53, DNS port)
timeout: Connection timeout in seconds (default: 3.0)
host: Host to connect to (default: 8.8.8.8, Google's public DNS).
port: Port to connect to (default: 53, DNS port).
timeout: Connection timeout in seconds (default: 3.0).
Returns:
True if connection successful, False otherwise
bool: True if connection successful, False otherwise.
"""
# First try: DNS port check (fastest, works if DNS port is open)
try:
@@ -1109,8 +1097,7 @@ async def check_internet_connectivity_async(host: str = "8.8.8.8", port: int = 5
def parse_path_string(path_str: str) -> List[str]:
"""
Parse a path string to extract node IDs.
"""Parse a path string to extract node IDs.
Handles various formats:
- "11,98,a4,49,cd,5f,01" (comma-separated)
@@ -1119,10 +1106,10 @@ def parse_path_string(path_str: str) -> List[str]:
- "01,5f (2 hops)" (with hop count suffix)
Args:
path_str: Path string in various formats
path_str: Path string in various formats.
Returns:
List of 2-character uppercase hex node IDs
List[str]: List of 2-character uppercase hex node IDs.
"""
if not path_str:
return []
@@ -1142,18 +1129,17 @@ def parse_path_string(path_str: str) -> List[str]:
return [match.upper() for match in hex_matches]
def calculate_path_distances(bot, path_str: str) -> Tuple[str, str]:
"""
Calculate path distance metrics from a path string.
def calculate_path_distances(bot: Any, path_str: str) -> Tuple[str, str]:
"""Calculate path distance metrics from a path string.
Args:
bot: Bot instance (must have db_manager)
path_str: Path string (e.g., "11,98,a4,49,cd,5f,01" or "01,5f (2 hops)" or "Direct")
bot: Bot instance (must have db_manager).
path_str: Path string (e.g., "11,98,a4,49,cd,5f,01" or "01,5f (2 hops)" or "Direct").
Returns:
Tuple of (path_distance_str, firstlast_distance_str)
- path_distance_str: Total distance with segment info (e.g., "123.4km (3 segs, 1 no-loc)" or "directly (0 hops)" or "locally (1 hop)")
- firstlast_distance_str: Distance between first and last repeater (e.g., "45.6km" or empty)
Tuple[str, str]: A tuple containing:
- path_distance_str: Total distance with segment info (e.g., "123.4km (3 segs, 1 no-loc)").
- firstlast_distance_str: Distance between first and last repeater (e.g., "45.6km").
"""
if not path_str:
return "directly (0 hops)", "N/A (direct)"
@@ -1253,16 +1239,15 @@ def calculate_path_distances(bot, path_str: str) -> Tuple[str, str]:
return "", ""
def _get_node_location_from_db(bot, node_id: str) -> Optional[Tuple[float, float]]:
"""
Get location for a node ID from the database.
def _get_node_location_from_db(bot: Any, node_id: str) -> Optional[Tuple[float, float]]:
"""Get location for a node ID from the database.
Args:
bot: Bot instance (must have db_manager)
node_id: 2-character hex node ID (e.g., "01", "5f")
bot: Bot instance (must have db_manager).
node_id: 2-character hex node ID (e.g., "01", "5f").
Returns:
Tuple of (latitude, longitude) or None if not found
Optional[Tuple[float, float]]: Tuple of (latitude, longitude) or None if not found.
"""
if not hasattr(bot, 'db_manager'):
return None
@@ -1299,24 +1284,23 @@ def _get_node_location_from_db(bot, node_id: str) -> Optional[Tuple[float, float
def format_keyword_response_with_placeholders(
response_format: str,
message,
bot,
message: Any,
bot: Any,
mesh_info: Optional[Dict[str, Any]] = None
) -> str:
"""
Format a keyword response string with all available placeholders.
"""Format a keyword response string with all available placeholders.
Supports both message-based placeholders and mesh-info-based placeholders.
This is a shared function used by both Keywords and Scheduled_Messages.
Args:
response_format: Response format string with placeholders
message: MeshMessage instance (can be None for scheduled messages)
bot: Bot instance (must have config, db_manager)
mesh_info: Optional mesh network info dict (for scheduled message placeholders)
response_format: Response format string with placeholders.
message: MeshMessage instance (can be None for scheduled messages).
bot: Bot instance (must have config, db_manager).
mesh_info: Optional mesh network info dict (for scheduled message placeholders).
Returns:
Formatted response string
str: Formatted response string.
"""
try:
replacements = {}