mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 22:48:20 +00:00
docs: Add docstrings and type hints across modules for improved clarity and maintainability.
This commit is contained in:
+128
-32
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user