diff --git a/config.ini.example b/config.ini.example index 833c9bd..52f27e2 100644 --- a/config.ini.example +++ b/config.ini.example @@ -113,7 +113,7 @@ auto_manage_contacts = false # {timestamp}: Message timestamp in HH:MM:SS format # {path}: Message routing path (e.g., "01,5f (2 hops)") # {rssi}: Received Signal Strength Indicator in dBm -test = "Message received from {sender} | {connection_info} | Received at: {timestamp}" +test = "ack {sender}{phrase_part} | {connection_info} | Received at: {timestamp}" ping = "Pong!" pong = "Ping!" help = "Bot Help: test, ping, help, hello, cmd, advert, t phrase, @string, wx, aqi, sun, moon, solar, hfcond, satpass, prefix | Use 'help ' for details" @@ -177,9 +177,6 @@ meshcore_log_level = INFO # Example: "t hello world" -> "ack {sender}: hello world | {connection_info}" t_phrase = "ack {sender}: {phrase} | {connection_info}" -# Special syntax: Messages starting with "@" followed by a phrase (DM only) -# Example: "@hello world" -> "ack {sender}: hello world | {connection_info}" -@_phrase = "ack {sender}: {phrase} | {connection_info}" [External_Data] # Weather API key (future feature) diff --git a/modules/command_manager.py b/modules/command_manager.py index b90df16..8e64015 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -347,7 +347,7 @@ class CommandManager: # Group commands by category basic_commands = ['test', 'ping', 'help', 'cmd'] - custom_syntax = ['t_phrase', 'at_phrase'] # Use the actual command key + custom_syntax = ['t_phrase'] # Use the actual command key special_commands = ['advert'] weather_commands = ['wx', 'aqi'] solar_commands = ['sun', 'moon', 'solar', 'hfcond', 'satpass'] @@ -366,8 +366,6 @@ class CommandManager: # Add user-friendly aliases if cmd == 't_phrase': commands_list += f"• `t phrase` - {help_text}\n" - elif cmd == 'at_phrase': - commands_list += f"• `@{{string}}` - {help_text}\n" else: commands_list += f"• `{cmd}` - {help_text}\n" diff --git a/modules/commands/at_phrase_command.py b/modules/commands/at_phrase_command.py deleted file mode 100644 index dad084a..0000000 --- a/modules/commands/at_phrase_command.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -""" -At-Phrase command for the MeshCore Bot -Handles the '@{string}' syntax for acknowledgments (DM only) -""" - -from .base_command import BaseCommand -from ..models import MeshMessage - - -class AtPhraseCommand(BaseCommand): - """Handles the @{string} command (DM and non-Public channels)""" - - # Plugin metadata - name = "at_phrase" - keywords = [] # No keywords - only use custom syntax matching - description = "Responds to '@{string}' with ack + connection info (DM and non-Public channels)" - category = "custom_syntax" - requires_dm = False # Allow in channels, but restrict to non-Public channels in matches_custom_syntax - - def get_help_text(self) -> str: - return "Responds to '@{string}' with ack + connection info (DM and non-Public channels)." - - def matches_custom_syntax(self, message: MeshMessage) -> bool: - """Check if message matches @_phrase syntax""" - # Strip exclamation mark if present (for command-style messages) - content = message.content.strip() - if content.startswith('!'): - content = content[1:].strip() - - # Handle "@{string}" phrase syntax (DM and non-Public channels only) - if content.startswith('@') and len(content) > 1: - # Check if this is a DM or a non-Public channel - is_allowed = message.is_dm - if not message.is_dm and message.channel: - # Allow in all channels except "Public" - is_allowed = message.channel.lower() != "public" - - if is_allowed: - phrase = content[1:].strip() # Get everything after "@" and strip whitespace - return bool(phrase) # Make sure there's actually a phrase - return False - - def get_response_format(self) -> str: - """Get the response format from config""" - if self.bot.config.has_section('Custom_Syntax'): - format_str = self.bot.config.get('Custom_Syntax', '@_phrase', fallback=None) - return self._strip_quotes_from_config(format_str) if format_str else None - return None - - def format_response(self, message: MeshMessage, response_format: str) -> str: - """Override to handle phrase extraction""" - # Strip exclamation mark if present (for command-style messages) - content = message.content.strip() - if content.startswith('!'): - content = content[1:].strip() - phrase = content[1:].strip() # Get everything after "@" - - try: - connection_info = self.build_enhanced_connection_info(message) - timestamp = self.format_timestamp(message) - - return response_format.format( - sender=message.sender_id or "Unknown", - phrase=phrase, - connection_info=connection_info, - path=message.path or "Unknown", - timestamp=timestamp, - snr=message.snr or "Unknown" - ) - except (KeyError, ValueError) as e: - self.logger.warning(f"Error formatting @_phrase response: {e}") - return response_format - - async def execute(self, message: MeshMessage) -> bool: - """Execute the @{string} command""" - return await self.handle_keyword_match(message) diff --git a/modules/commands/help_command.py b/modules/commands/help_command.py index 45737a5..db9a38d 100644 --- a/modules/commands/help_command.py +++ b/modules/commands/help_command.py @@ -31,7 +31,6 @@ class HelpCommand(BaseCommand): """Get help text for a specific command""" # Map command aliases to their actual command names command_aliases = { - '@': 'at_phrase', 't': 't_phrase', 'advert': 'advert', 'test': 'test', @@ -83,7 +82,7 @@ class HelpCommand(BaseCommand): # Group commands by category basic_commands = ['test', 'ping', 'help'] - custom_syntax = ['t_phrase', 'at_phrase'] # Use the actual command key + custom_syntax = ['t_phrase'] # Use the actual command key special_commands = ['advert'] commands_list += "**Basic Commands:**\n" @@ -99,8 +98,6 @@ class HelpCommand(BaseCommand): # Add user-friendly aliases if cmd == 't_phrase': commands_list += f"• `t phrase` - {help_text}\n" - elif cmd == 'at_phrase': - commands_list += f"• `@{{string}}` - {help_text}\n" else: commands_list += f"• `{cmd}` - {help_text}\n" diff --git a/modules/commands/path_command.py b/modules/commands/path_command.py new file mode 100644 index 0000000..da88d60 --- /dev/null +++ b/modules/commands/path_command.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +""" +Path Decode Command for the MeshCore Bot +Decodes hex path data to show which repeaters were involved in message routing +""" + +import re +import time +from typing import List, Optional, Dict, Any +from .base_command import BaseCommand +from ..models import MeshMessage + + +class PathCommand(BaseCommand): + """Command for decoding path data to repeater names""" + + # Plugin metadata + name = "path" + keywords = ["path", "decode", "route"] + description = "Decode hex path data to show which repeaters were involved in message routing" + requires_dm = False + cooldown_seconds = 1 + category = "meshcore_info" + + def __init__(self, bot): + super().__init__(bot) + + def matches_keyword(self, message: MeshMessage) -> bool: + """Check if message starts with 'path' keyword""" + content = message.content.strip() + + # Handle exclamation prefix + if content.startswith('!'): + content = content[1:].strip() + + # Check if message starts with any of our keywords + content_lower = content.lower() + for keyword in self.keywords: + if content_lower.startswith(keyword + ' '): + return True + return False + + async def execute(self, message: MeshMessage) -> bool: + """Execute path decode command""" + self.logger.info(f"Path command executed with content: {message.content}") + + # Parse the message content to extract path data + content = message.content.strip() + parts = content.split() + + if len(parts) < 2: + response = self.get_help() + else: + # Extract path data from the command + path_input = " ".join(parts[1:]) + response = await self._decode_path(path_input) + + # Send the response + await self.bot.command_manager.send_response(message, response) + return True + + async def _decode_path(self, path_input: str) -> str: + """Decode hex path data to repeater names""" + try: + # Parse the path input - handle various formats + # Examples: "11,98,a4,49,cd,5f,01" or "11 98 a4 49 cd 5f 01" or "1198a449cd5f01" + path_input = path_input.replace(',', ' ').replace(':', ' ') + + # Extract hex values using regex + hex_pattern = r'[0-9a-fA-F]{2}' + hex_matches = re.findall(hex_pattern, path_input) + + if not hex_matches: + return "❌ No valid hex values found in path data. Use format like: 11,98,a4,49,cd,5f,01" + + # Convert to uppercase for consistency + node_ids = [match.upper() for match in hex_matches] + + self.logger.info(f"Decoding path with {len(node_ids)} nodes: {','.join(node_ids)}") + + # Look up repeater names for each node ID + repeater_info = await self._lookup_repeater_names(node_ids) + + # Format the response + return self._format_path_response(node_ids, repeater_info) + + except Exception as e: + self.logger.error(f"Error decoding path: {e}") + return f"❌ Error decoding path: {e}" + + async def _lookup_repeater_names(self, node_ids: List[str]) -> Dict[str, Dict[str, Any]]: + """Look up repeater names for given node IDs""" + repeater_info = {} + + try: + # First try to get data from API cache (like prefix command does) + api_data = await self._get_api_cache_data() + + # Query the database for repeaters with matching prefixes + # Node IDs are typically the first 2 characters of the public key + for node_id in node_ids: + # Check API cache first + if api_data and node_id in api_data: + api_prefix_data = api_data[node_id] + if api_prefix_data['node_names']: + # Use API data + if len(api_prefix_data['node_names']) > 1: + # Multiple matches - show collision warning + repeater_info[node_id] = { + 'found': True, + 'collision': True, + 'matches': len(api_prefix_data['node_names']), + 'node_id': node_id, + 'repeaters': [ + { + 'name': name, + 'public_key': f"{node_id}...", + 'device_type': 'Unknown', + 'last_seen': 'API', + 'is_active': True, + 'source': 'api' + } for name in api_prefix_data['node_names'] + ] + } + else: + # Single match + repeater_info[node_id] = { + 'name': api_prefix_data['node_names'][0], + 'public_key': f"{node_id}...", + 'device_type': 'Unknown', + 'last_seen': 'API', + 'is_active': True, + 'found': True, + 'collision': False, + 'source': 'api' + } + continue + + # Fallback to database if API cache doesn't have this prefix + query = ''' + SELECT name, public_key, device_type, last_seen, is_active + FROM repeater_contacts + WHERE public_key LIKE ? + ORDER BY is_active DESC, last_seen DESC + ''' + + prefix_pattern = f"{node_id}%" + results = self.bot.db_manager.execute_query(query, (prefix_pattern,)) + + if results: + # Check for ID collisions (multiple repeaters with same prefix) + if len(results) > 1: + # Multiple matches - show collision warning + repeater_info[node_id] = { + 'found': True, + 'collision': True, + 'matches': len(results), + 'node_id': node_id, + 'repeaters': [ + { + 'name': row['name'], + 'public_key': row['public_key'], + 'device_type': row['device_type'], + 'last_seen': row['last_seen'], + 'is_active': row['is_active'] + } for row in results + ] + } + else: + # Single match + row = results[0] + repeater_info[node_id] = { + 'name': row['name'], + 'public_key': row['public_key'], + 'device_type': row['device_type'], + 'last_seen': row['last_seen'], + 'is_active': row['is_active'], + 'found': True, + 'collision': False + } + else: + # Also check device contacts for active repeaters + device_matches = [] + if hasattr(self.bot.meshcore, 'contacts'): + for contact_key, contact_data in self.bot.meshcore.contacts.items(): + public_key = contact_data.get('public_key', contact_key) + if public_key.startswith(node_id): + # Check if this is a repeater + if hasattr(self.bot, 'repeater_manager') and self.bot.repeater_manager._is_repeater_device(contact_data): + name = contact_data.get('adv_name', contact_data.get('name', 'Unknown')) + device_matches.append({ + 'name': name, + 'public_key': public_key, + 'device_type': contact_data.get('type', 'Unknown'), + 'last_seen': 'Active', + 'is_active': True, + 'source': 'device' + }) + + if device_matches: + if len(device_matches) > 1: + # Multiple device matches - show collision warning + repeater_info[node_id] = { + 'found': True, + 'collision': True, + 'matches': len(device_matches), + 'node_id': node_id, + 'repeaters': device_matches + } + else: + # Single device match + match = device_matches[0] + repeater_info[node_id] = { + 'name': match['name'], + 'public_key': match['public_key'], + 'device_type': match['device_type'], + 'last_seen': match['last_seen'], + 'is_active': match['is_active'], + 'found': True, + 'collision': False, + 'source': 'device' + } + else: + repeater_info[node_id] = { + 'found': False, + 'node_id': node_id + } + + except Exception as e: + self.logger.error(f"Error looking up repeater names: {e}") + # Return basic info for all nodes + for node_id in node_ids: + repeater_info[node_id] = { + 'found': False, + 'node_id': node_id, + 'error': str(e) + } + + return repeater_info + + async def _get_api_cache_data(self) -> Optional[Dict[str, Dict[str, Any]]]: + """Get API cache data from the prefix command if available""" + try: + # Try to get the prefix command instance and its cache data + if hasattr(self.bot, 'command_manager'): + prefix_cmd = self.bot.command_manager.commands.get('prefix') + if prefix_cmd and hasattr(prefix_cmd, 'cache_data'): + # Check if cache is valid + current_time = time.time() + if current_time - prefix_cmd.cache_timestamp > prefix_cmd.cache_duration: + await prefix_cmd.refresh_cache() + return prefix_cmd.cache_data + except Exception as e: + self.logger.warning(f"Could not get API cache data: {e}") + return None + + def _format_path_response(self, node_ids: List[str], repeater_info: Dict[str, Dict[str, Any]]) -> str: + """Format the path decode response (max 130 chars per line)""" + # Group results by found/not found/collision + found_repeaters = [] + collision_nodes = [] + unknown_nodes = [] + + for node_id in node_ids: + info = repeater_info.get(node_id, {}) + if info.get('found', False): + if info.get('collision', False): + collision_nodes.append((node_id, info)) + else: + found_repeaters.append((node_id, info)) + else: + unknown_nodes.append(node_id) + + # Build response lines (each max 130 chars) + lines = [] + + # Show found repeaters (compact format) + if found_repeaters: + for node_id, info in found_repeaters: + name = info['name'] + + # Truncate name if too long + if len(name) > 27: + name = name[:24] + "..." + + line = f"{node_id}: {name}" + if len(line) > 130: + line = line[:127] + "..." + lines.append(line) + + # Show collision nodes (compact format) + if collision_nodes: + for node_id, info in collision_nodes: + matches = info.get('matches', 0) + line = f"{node_id}: {matches} repeaters" + if len(line) > 130: + line = line[:127] + "..." + lines.append(line) + + # Show unknown nodes (compact format) + if unknown_nodes: + unknown_str = f"Unknown: {','.join(unknown_nodes)}" + if len(unknown_str) > 130: + # Split if too long + for node_id in unknown_nodes: + lines.append(f"Unknown: {node_id}") + else: + lines.append(unknown_str) + + return "\n".join(lines) + + def get_help(self) -> str: + """Get help text for the path command""" + return """Path Decode: !path + +Decode hex path to show repeaters involved in routing. + +Examples: +• !path 11,98,a4,49,cd,5f,01 +• !path 11 98 a4 49 cd 5f 01 +• !path 1198a449cd5f01 + +Shows repeater names, collisions, and unknown nodes. +Uses local database - run !repeater scan to update.""" diff --git a/modules/commands/prefix_command.py b/modules/commands/prefix_command.py index b1416b5..9b88e85 100644 --- a/modules/commands/prefix_command.py +++ b/modules/commands/prefix_command.py @@ -37,7 +37,7 @@ class PrefixCommand(BaseCommand): self.session = None def get_help_text(self) -> str: - return "Look up repeaters by two-character prefix. Uses API data with local database fallback. Usage: 'prefix 1A', 'prefix free' (list available prefixes), or 'prefix refresh'." + return "Look up repeaters by two-character prefix. Usage: 'prefix 1A', 'prefix free' (list available prefixes), or 'prefix refresh'." def matches_keyword(self, message: MeshMessage) -> bool: """Check if message starts with 'prefix' keyword""" @@ -47,8 +47,9 @@ class PrefixCommand(BaseCommand): if content.startswith('!'): content = content[1:].strip() - # Check if message starts with 'prefix' - return content.lower().startswith('prefix ') + # Check if message starts with 'prefix' (with or without space) + content_lower = content.lower() + return content_lower == 'prefix' or content_lower.startswith('prefix ') async def execute(self, message: MeshMessage) -> bool: """Execute the prefix command""" @@ -61,7 +62,7 @@ class PrefixCommand(BaseCommand): # Parse the command parts = content.split() if len(parts) < 2: - response = "Usage: prefix (e.g., 'prefix 1A'), 'prefix free', or 'prefix refresh'" + response = self.get_help_text() return await self.send_response(message, response) command = parts[1].upper() @@ -266,7 +267,7 @@ class PrefixCommand(BaseCommand): if not free_prefixes: return "❌ No free prefixes found (all 254 valid prefixes are in use)" - response = f"🆓 Available Prefixes ({len(free_prefixes)} of {total_free} free shown):\n" + response = f"Available Prefixes ({len(free_prefixes)} of {total_free} free):\n" # Format as a grid for better readability for i, prefix in enumerate(free_prefixes, 1): @@ -280,7 +281,7 @@ class PrefixCommand(BaseCommand): if len(free_prefixes) % 5 != 0: response += "\n" - response += f"\n💡 Generate a key for a specific prefix at https://gessaman.com/mc-keygen" + response += f"\n💡 Generate a custom key: https://gessaman.com/mc-keygen" return response diff --git a/modules/commands/sports_command.py b/modules/commands/sports_command.py index 78cb978..c2c6196 100644 --- a/modules/commands/sports_command.py +++ b/modules/commands/sports_command.py @@ -39,8 +39,34 @@ class SportsCommand(BaseCommand): # Custom team abbreviations to distinguish between leagues TEAM_ABBREVIATIONS = { # NWSL teams - use custom abbreviations to distinguish from MLS - '15363': 'SEA-W', # Seattle Reign (Women's) - '20905': 'LOU-W', # Racing Louisville (Women's) + '21422': 'LA-W', # Angel City FC (Women's) + '22187': 'BAY-W', # Bay FC (Women's) + '15360': 'CHI-W', # Chicago Stars FC (Women's) + '15364': 'GFC-W', # Gotham FC (Women's) + '17346': 'HOU-W', # Houston Dash (Women's) + '20907': 'KC-W', # Kansas City Current (Women's) + '15366': 'NC-W', # North Carolina Courage (Women's) + '18206': 'ORL-W', # Orlando Pride (Women's) + '15362': 'POR-W', # Portland Thorns FC (Women's) + '20905': 'LOU-W', # Racing Louisville FC (Women's) + '21423': 'SD-W', # San Diego Wave FC (Women's) + '15363': 'SEA-W', # Seattle Reign FC (Women's) + '19141': 'UTA-W', # Utah Royals (Women's) + '15365': 'WAS-W', # Washington Spirit (Women's) + # WNBA teams - use custom abbreviations to distinguish from NBA + '14': 'SEA-W', # Seattle Storm (Women's) + '9': 'NY-W', # New York Liberty (Women's) + '6': 'LA-W', # Los Angeles Sparks (Women's) + '19': 'CHI-W', # Chicago Sky (Women's) + '20': 'ATL-W', # Atlanta Dream (Women's) + '18': 'CON-W', # Connecticut Sun (Women's) + '3': 'DAL-W', # Dallas Wings (Women's) + '129689': 'GS-W', # Golden State Valkyries (Women's) + '5': 'IND-W', # Indiana Fever (Women's) + '17': 'LV-W', # Las Vegas Aces (Women's) + '8': 'MIN-W', # Minnesota Lynx (Women's) + '11': 'PHX-W', # Phoenix Mercury (Women's) + '16': 'WSH-W', # Washington Mystics (Women's) } # Team mappings for common searches @@ -236,6 +262,34 @@ class SportsCommand(BaseCommand): 'knicks': {'sport': 'basketball', 'league': 'nba', 'team_id': '18'}, 'pelicans': {'sport': 'basketball', 'league': 'nba', 'team_id': '3'}, + # WNBA Teams + 'storm': {'sport': 'basketball', 'league': 'wnba', 'team_id': '14'}, + 'seattle storm': {'sport': 'basketball', 'league': 'wnba', 'team_id': '14'}, + 'liberty': {'sport': 'basketball', 'league': 'wnba', 'team_id': '9'}, + 'new york liberty': {'sport': 'basketball', 'league': 'wnba', 'team_id': '9'}, + 'sparks': {'sport': 'basketball', 'league': 'wnba', 'team_id': '6'}, + 'los angeles sparks': {'sport': 'basketball', 'league': 'wnba', 'team_id': '6'}, + 'sky': {'sport': 'basketball', 'league': 'wnba', 'team_id': '19'}, + 'chicago sky': {'sport': 'basketball', 'league': 'wnba', 'team_id': '19'}, + 'dream': {'sport': 'basketball', 'league': 'wnba', 'team_id': '20'}, + 'atlanta dream': {'sport': 'basketball', 'league': 'wnba', 'team_id': '20'}, + 'sun': {'sport': 'basketball', 'league': 'wnba', 'team_id': '18'}, + 'connecticut sun': {'sport': 'basketball', 'league': 'wnba', 'team_id': '18'}, + 'wings': {'sport': 'basketball', 'league': 'wnba', 'team_id': '3'}, + 'dallas wings': {'sport': 'basketball', 'league': 'wnba', 'team_id': '3'}, + 'valkyries': {'sport': 'basketball', 'league': 'wnba', 'team_id': '129689'}, + 'golden state valkyries': {'sport': 'basketball', 'league': 'wnba', 'team_id': '129689'}, + 'fever': {'sport': 'basketball', 'league': 'wnba', 'team_id': '5'}, + 'indiana fever': {'sport': 'basketball', 'league': 'wnba', 'team_id': '5'}, + 'aces': {'sport': 'basketball', 'league': 'wnba', 'team_id': '17'}, + 'las vegas aces': {'sport': 'basketball', 'league': 'wnba', 'team_id': '17'}, + 'lynx': {'sport': 'basketball', 'league': 'wnba', 'team_id': '8'}, + 'minnesota lynx': {'sport': 'basketball', 'league': 'wnba', 'team_id': '8'}, + 'mercury': {'sport': 'basketball', 'league': 'wnba', 'team_id': '11'}, + 'phoenix mercury': {'sport': 'basketball', 'league': 'wnba', 'team_id': '11'}, + 'mystics': {'sport': 'basketball', 'league': 'wnba', 'team_id': '16'}, + 'washington mystics': {'sport': 'basketball', 'league': 'wnba', 'team_id': '16'}, + # NHL Teams (limited data available from API) 'kraken': {'sport': 'hockey', 'league': 'nhl', 'team_id': '58'}, 'seattle kraken': {'sport': 'hockey', 'league': 'nhl', 'team_id': '58'}, @@ -505,6 +559,11 @@ class SportsCommand(BaseCommand): 'nba': {'sport': 'basketball', 'league': 'nba'}, 'basketball': {'sport': 'basketball', 'league': 'nba'}, + # WNBA + 'wnba': {'sport': 'basketball', 'league': 'wnba'}, + 'womens basketball': {'sport': 'basketball', 'league': 'wnba'}, + 'womens': {'sport': 'basketball', 'league': 'wnba'}, + # NHL 'nhl': {'sport': 'hockey', 'league': 'nhl'}, 'hockey': {'sport': 'hockey', 'league': 'nhl'}, @@ -532,23 +591,23 @@ class SportsCommand(BaseCommand): # Define city mappings to team names city_mappings = { - 'seattle': ['seahawks', 'mariners', 'sounders', 'kraken', 'reign'], - 'chicago': ['bears', 'cubs', 'white sox', 'fire'], - 'new york': ['giants', 'jets', 'yankees', 'mets', 'knicks', 'nyc fc', 'red bulls'], - 'ny': ['giants', 'jets', 'yankees', 'mets', 'knicks', 'nyc fc', 'red bulls'], - 'los angeles': ['rams', 'dodgers', 'lakers', 'la galaxy', 'lafc'], - 'la': ['rams', 'dodgers', 'lakers', 'la galaxy', 'lafc'], + 'seattle': ['seahawks', 'mariners', 'sounders', 'kraken', 'reign', 'storm'], + 'chicago': ['bears', 'cubs', 'white sox', 'fire', 'sky'], + 'new york': ['giants', 'jets', 'yankees', 'mets', 'knicks', 'nyc fc', 'red bulls', 'liberty'], + 'ny': ['giants', 'jets', 'yankees', 'mets', 'knicks', 'nyc fc', 'red bulls', 'liberty'], + 'los angeles': ['rams', 'dodgers', 'lakers', 'la galaxy', 'lafc', 'sparks'], + 'la': ['rams', 'dodgers', 'lakers', 'la galaxy', 'lafc', 'sparks'], 'miami': ['dolphins', 'marlins', 'heat', 'inter miami'], 'boston': ['patriots', 'red sox', 'celtics', 'revolution'], 'philadelphia': ['eagles', 'phillies', '76ers', 'union'], 'philadelphia': ['eagles', 'phillies', '76ers', 'union'], - 'atlanta': ['falcons', 'braves', 'hawks', 'atlanta united'], + 'atlanta': ['falcons', 'braves', 'hawks', 'atlanta united', 'dream'], 'houston': ['texans', 'astros', 'dynamo'], - 'dallas': ['cowboys', 'rangers', 'stars', 'fc dallas'], + 'dallas': ['cowboys', 'rangers', 'stars', 'fc dallas', 'wings'], 'denver': ['broncos', 'rockies', 'rapids'], 'detroit': ['lions', 'tigers', 'pistons'], - 'minnesota': ['vikings', 'twins', 'timberwolves', 'minnesota united'], - 'minneapolis': ['vikings', 'twins', 'timberwolves', 'minnesota united'], + 'minnesota': ['vikings', 'twins', 'timberwolves', 'minnesota united', 'lynx'], + 'minneapolis': ['vikings', 'twins', 'timberwolves', 'minnesota united', 'lynx'], 'cleveland': ['browns', 'guardians', 'cavaliers'], 'cincinnati': ['bengals', 'reds', 'fc cincinnati'], 'pittsburgh': ['steelers', 'pirates', 'penguins'], @@ -557,20 +616,23 @@ class SportsCommand(BaseCommand): 'tampa bay': ['buccaneers', 'rays', 'lightning'], 'kansas city': ['chiefs', 'royals', 'sporting kc'], 'kc': ['chiefs', 'royals', 'sporting kc'], + 'washington': ['commanders', 'nationals', 'wizards', 'dc united', 'mystics'], + 'dc': ['commanders', 'nationals', 'wizards', 'dc united', 'mystics'], + 'phoenix': ['cardinals', 'diamondbacks', 'suns', 'mercury'], + 'indiana': ['colts', 'pacers', 'fever'], + 'indianapolis': ['colts', 'pacers', 'fever'], + 'las vegas': ['raiders', 'aces', 'golden knights'], + 'connecticut': ['sun'], 'arizona': ['cardinals', 'diamondbacks', 'coyotes'], - 'phoenix': ['cardinals', 'diamondbacks', 'coyotes'], - 'san francisco': ['49ers', 'giants', 'warriors', 'earthquakes'], - 'sf': ['49ers', 'giants', 'warriors', 'earthquakes'], + 'golden state': ['warriors', 'valkyries'], + 'san francisco': ['49ers', 'giants', 'warriors', 'earthquakes', 'valkyries'], + 'sf': ['49ers', 'giants', 'warriors', 'earthquakes', 'valkyries'], 'san diego': ['chargers', 'padres', 'san diego fc'], 'sd': ['chargers', 'padres', 'san diego fc'], - 'washington': ['commanders', 'nationals', 'wizards', 'dc united'], - 'dc': ['commanders', 'nationals', 'wizards', 'dc united'], - 'indianapolis': ['colts', 'pacers'], 'ind': ['colts', 'pacers'], 'nashville': ['titans', 'predators', 'nashville sc'], 'tennessee': ['titans', 'predators', 'nashville sc'], 'ten': ['titans', 'predators', 'nashville sc'], - 'las vegas': ['raiders', 'golden knights'], 'lv': ['raiders', 'golden knights'], 'louisville': ['racing'], 'carolina': ['panthers', 'hornets'], @@ -869,7 +931,7 @@ class SportsCommand(BaseCommand): # Otherwise, treat as single team query team_info = self.TEAM_MAPPINGS.get(team_name) if not team_info: - return f"Team/League '{team_name}' not found. Try: seahawks, mariners, sounders, kraken, chiefs, lfc, mlb, nfl, mls, epl, etc." + return f"Team/League '{team_name}' not found. Try: seahawks, mariners, sounders, kraken, storm, chiefs, lfc, mlb, nfl, mls, wnba, epl, etc." try: score_info = await self.fetch_team_score(team_info) diff --git a/modules/commands/test_command.py b/modules/commands/test_command.py index 1c3db94..7fab09a 100644 --- a/modules/commands/test_command.py +++ b/modules/commands/test_command.py @@ -22,14 +22,18 @@ class TestCommand(BaseCommand): return self.description def matches_keyword(self, message: MeshMessage) -> bool: - """Override to implement special test keyword matching""" - content_lower = message.content.lower().strip() + """Override to implement special test keyword matching with optional phrase""" + # Strip exclamation mark if present (for command-style messages) + content = message.content.strip() + if content.startswith('!'): + content = content[1:].strip() - # For "test", only match if it's the first word or its own word - # Split by whitespace and clean up punctuation - words = re.findall(r'\b\w+\b', content_lower) - if words and (words[0] == "test" or "test" in words): - return True + # Handle "test" alone or "test " with phrase + if content.lower() == "test": + return True # Just "test" by itself + elif (content.startswith('test ') or content.startswith('Test ')) and len(content) > 5: + phrase = content[5:].strip() # Get everything after "test " and strip whitespace + return bool(phrase) # Make sure there's actually a phrase return False @@ -40,6 +44,39 @@ class TestCommand(BaseCommand): return self._strip_quotes_from_config(format_str) if format_str else None return None + def format_response(self, message: MeshMessage, response_format: str) -> str: + """Override to handle phrase extraction""" + # Strip exclamation mark if present (for command-style messages) + content = message.content.strip() + if content.startswith('!'): + content = content[1:].strip() + + # Extract phrase if present, otherwise use empty string + if content.lower() == "test": + phrase = "" + else: + phrase = content[5:].strip() # Get everything after "test " + + try: + connection_info = self.build_enhanced_connection_info(message) + timestamp = self.format_timestamp(message) + + # Format phrase part - add colon and space if phrase exists + phrase_part = f": {phrase}" if phrase else "" + + return response_format.format( + sender=message.sender_id or "Unknown", + phrase=phrase, + phrase_part=phrase_part, + connection_info=connection_info, + path=message.path or "Unknown", + timestamp=timestamp, + snr=message.snr or "Unknown" + ) + except (KeyError, ValueError) as e: + self.logger.warning(f"Error formatting test response: {e}") + return response_format + async def execute(self, message: MeshMessage) -> bool: """Execute the test command""" return await self.handle_keyword_match(message) diff --git a/modules/core.py b/modules/core.py index ad950dd..fc4e137 100644 --- a/modules/core.py +++ b/modules/core.py @@ -211,7 +211,7 @@ auto_manage_contacts = false # {snr}: Signal-to-noise ratio in dB # {timestamp}: Message timestamp in HH:MM:SS format # {path}: Message routing path (e.g., "01,5f (2 hops)") -test = "Message received from {sender} | {connection_info} | Received at: {timestamp}" +test = "ack {sender}{phrase_part} | {connection_info} | Received at: {timestamp}" ping = "Pong!" pong = "Ping!" help = "Bot Help: test, ping, help, hello, cmd, advert, t phrase, @string, wx, aqi, sun, moon, solar, hfcond, satpass | Use 'help ' for details" @@ -274,9 +274,6 @@ meshcore_log_level = INFO # Example: "t hello world" -> "ack {sender}: hello world | {connection_info}" t_phrase = "ack {sender}: {phrase} | {connection_info}" -# Special syntax: Messages starting with "@" followed by a phrase (DM only) -# Example: "@hello world" -> "ack {sender}: hello world | {connection_info}" -@_phrase = "ack {sender}: {phrase} | {connection_info}" [External_Data] # Weather API key (future feature)