diff --git a/config.ini.example b/config.ini.example index 8fbfc28..b2ae7d7 100644 --- a/config.ini.example +++ b/config.ini.example @@ -154,6 +154,9 @@ respond_to_dms = true # Example: channel_keywords = help,ping,test,hello # channel_keywords = +# Set a custom prefix length for the public keys to identify repeaters +prefix_bytes = 1 + [Banned_Users] # List of banned sender names (comma-separated). Matching is prefix (starts-with): # "Awful Username" also matches "Awful Username πŸ†". No bot responses in channels or DMs. @@ -285,6 +288,25 @@ help = "Bot Help: test (or t), ping, help, hello, cmd, advert, wx, aqi, sun, moo # Override 'cmd' command output # cmd = "Available commands: test (or t), ping, help, hello, cmd, advert, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats" +[RandomLine] +# Configurable command to act on a trigger word and respond with a random line from its file +# triggers. = csv list of trigger words +# file. = path to text file +# prefix. = string prepended to the chosen line (often an emoji) + +# default prefix (blank = no prefix) +prefix.default = + +# Mom Jokes +triggers.momjoke = momjoke,momjokes,mom joke,mom jokes,mom-joke,mom-jokes +file.momjoke = data/randomlines/momjokes.txt +prefix.momjoke = πŸ₯Έ + +# Fun Facts +triggers.funfact = funfact,funfacts,fun fact,fun facts,fun-fact,fun-facts +file.funfact = data/randomlines/funfacts.txt +prefix.funfact = πŸ’‘ + [Scheduled_Messages] # Scheduled message format: HHMM = channel:message # Time format: HHMM (24-hour, no colon) diff --git a/data/randomlines/funfacts.txt b/data/randomlines/funfacts.txt new file mode 100644 index 0000000..af4cad3 --- /dev/null +++ b/data/randomlines/funfacts.txt @@ -0,0 +1,4 @@ +Packets are tiny on purpose to keep airtime low. +RF hates metal boxes but loves elevation. +Weather usually affects LoRa less than Wi-Fi. +You can often improve signal just by rotating the antenna. diff --git a/data/randomlines/momjokes.txt b/data/randomlines/momjokes.txt new file mode 100644 index 0000000..0d79c7b --- /dev/null +++ b/data/randomlines/momjokes.txt @@ -0,0 +1,3 @@ +"Mom, can I get $20?” She replies, β€œDoes it look like I’m made of money?” Son: β€œIsn’t that what M.O.M. stands for?” +Our wedding was so beautiful, even the cake was in tiers! +I used to be a vegetarian, but then I had too much beef with the other moms. diff --git a/docs/earthquake-service.md b/docs/earthquake-service.md index 86e962b..735dbf6 100644 --- a/docs/earthquake-service.md +++ b/docs/earthquake-service.md @@ -57,3 +57,9 @@ Earthquake M3.2 mb | 12km NW of Borrego Springs, CA | 14:32:15 UTC | depth 12 km https://earthquake.usgs.gov/earthquakes/eventpage/ci40623456 ``` When `send_link = false`, only the first line is sent and no link is posted. + +--- + +## Credit + +Original code and idea by [davidkjackson54](https://github.com/davidkjackson54). diff --git a/modules/command_manager.py b/modules/command_manager.py index 8155734..d13220f 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -11,6 +11,7 @@ from dataclasses import dataclass from typing import List, Dict, Tuple, Optional, Any from datetime import datetime import pytz +import random from meshcore import EventType from .models import MeshMessage @@ -639,7 +640,116 @@ class CommandManager: self.logger.warning(f"Error formatting response for '{keyword}': {e}") matches.append((keyword, response_format)) - return matches + return matches + + def _normalize_trigger_text(self, raw: str) -> str: + """ + Normalize user input / triggers: + - strip configured command_prefix if present + - strip legacy leading "!" if no command_prefix configured + - lowercase + - trim + collapse whitespace + """ + if raw is None: + return "" + text = raw.strip() + + # Mirror check_keywords() prefix handling + if self.command_prefix: + if not text.startswith(self.command_prefix): + return "" # No prefix -> treat as non-matchable + text = text[len(self.command_prefix):].strip() + else: + # Backward compatibility + if text.startswith('!'): + text = text[1:].strip() + + # case-insensitive + ignore extra spaces + return " ".join(text.lower().split()) + + def match_randomline(self, message: MeshMessage) -> Optional[Tuple[str, str]]: + """ + Exact-match message content against RandomLine triggers. + Returns (key, response) or None. + Matching is case-insensitive and ignores extra spaces. + """ + if not self.bot.config.has_section('RandomLine'): + return None + + # Start with the same content + prefix stripping logic as check_keywords() + content = (message.content or "").strip() + + # Check for command prefix if configured + if self.command_prefix: + if not content.startswith(self.command_prefix): + return None + content = content[len(self.command_prefix):].strip() + else: + # Legacy "!" prefix compatibility + if content.startswith('!'): + content = content[1:].strip() + + # Normalize: lowercase + collapse whitespace + content_norm = " ".join(content.lower().split()) + if not content_norm: + return None + + # Build trigger -> key map from config: triggers. = csv list + trigger_map = {} + for cfg_key, cfg_val in self.bot.config.items('RandomLine'): + if not cfg_key.startswith('triggers.'): + continue + + key = cfg_key.split('.', 1)[1].strip() + if not key: + continue + + raw_triggers = [t.strip() for t in (cfg_val or "").split(",") if t.strip()] + for trig in raw_triggers: + trig_norm = " ".join(trig.lower().split()) + if trig_norm: + trigger_map[trig_norm] = key + + key = trigger_map.get(content_norm) + if not key: + return None + + # Channel restrictions (mirror the plain keyword restrictions) + if message.is_dm: + if not self.bot.config.getboolean('Channels', 'respond_to_dms', fallback=True): + return None + else: + if message.channel not in self.monitor_channels: + return None + if not self._is_channel_trigger_allowed(key, message): + return None + + file_path = self.bot.config.get('RandomLine', f'file.{key}', fallback='').strip() + if not file_path: + self.logger.warning(f"RandomLine matched '{key}' but missing config file.{key}") + return None + + # Read usable lines + try: + with open(file_path, "r", encoding="utf-8") as f: + lines = [ln.strip() for ln in f.readlines()] + lines = [ln for ln in lines if ln] # drop blank lines + except Exception as e: + self.logger.error(f"RandomLine error reading {file_path} for '{key}': {e}", exc_info=True) + return None + + if not lines: + self.logger.warning(f"RandomLine file is empty for '{key}': {file_path}") + return None + + chosen = random.choice(lines) + + prefix = self.bot.config.get('RandomLine', f'prefix.{key}', fallback='').strip() + if not prefix: + prefix = (self.bot.config.get('RandomLine', 'prefix.default', fallback='') or '').strip() + + response = f"{prefix} {chosen}".strip() if prefix else chosen + return key, response async def handle_advert_command(self, message: MeshMessage): """Handle the advert command from DM. diff --git a/modules/commands/path_command.py b/modules/commands/path_command.py index ce2ad5d..625a0d0 100644 --- a/modules/commands/path_command.py +++ b/modules/commands/path_command.py @@ -224,8 +224,16 @@ class PathCommand(BaseCommand): path_input = path_input.replace(',', ' ').replace(':', ' ') # Extract hex values using regex - hex_pattern = r'[0-9a-fA-F]{2}' + # Try configured width first + n = getattr(self.bot, "prefix_hex_chars", 2) + hex_pattern = rf'[0-9a-fA-F]{{{n}}}' hex_matches = re.findall(hex_pattern, path_input) + + # Backward compatibility: + # if no matches and we're expecting >2 chars, try legacy 2-char paths + if not hex_matches and n > 2: + legacy_pattern = r'[0-9a-fA-F]{2}' + hex_matches = re.findall(legacy_pattern, path_input) if not hex_matches: return self.translate('commands.path.no_valid_hex') @@ -266,7 +274,7 @@ class PathCommand(BaseCommand): api_data = None # Query the database for repeaters with matching prefixes - # Node IDs are typically the first 2 characters of the public key + # Node IDs are the configured prefix of the public key (see Bot.prefix_bytes) for node_id in node_ids: # Test dependency injection: use provided lookup when available if lookup_func is not None: @@ -1313,7 +1321,8 @@ class PathCommand(BaseCommand): best_method = None for repeater in repeaters: - candidate_prefix = repeater.get('public_key', '')[:2].lower() if repeater.get('public_key') else None + pk = repeater.get('public_key') or '' + candidate_prefix = self.bot.key_prefix(pk).lower() if pk else None candidate_public_key = repeater.get('public_key', '').lower() if repeater.get('public_key') else None if not candidate_prefix: continue @@ -1712,7 +1721,7 @@ class PathCommand(BaseCommand): else: # Try to decode even single nodes (e.g., "01" should be decoded to a repeater name) # Check if path_part looks like it contains hex values - hex_pattern = r'[0-9a-fA-F]{2}' + hex_pattern = rf'[0-9a-fA-F]{{{self.bot.prefix_hex_chars}}}' if re.search(hex_pattern, path_part): # Looks like hex values, try to decode return await self._decode_path(path_part) diff --git a/modules/commands/prefix_command.py b/modules/commands/prefix_command.py index 61fc05d..11133f9 100644 --- a/modules/commands/prefix_command.py +++ b/modules/commands/prefix_command.py @@ -26,19 +26,19 @@ class PrefixCommand(BaseCommand): # Plugin metadata name = "prefix" keywords = ['prefix', 'repeater', 'lookup'] - description = "Look up repeaters by two-character prefix (e.g., 'prefix 1A')" + description = "Look up repeaters by prefix (e.g., 'prefix 1A' or 'prefix 2299')" category = "meshcore_info" requires_dm = False cooldown_seconds = 2 requires_internet = False # Will be set to True in __init__ if API is configured - + # Documentation - short_description = "Look up repeaters by two-character prefix and show their locations (if known)" - usage = "prefix " - examples = ["prefix 1A", "prefix free"] + short_description = "Look up repeaters by prefix and show their locations (if known)" + usage = "prefix " + examples = ["prefix 1A", "prefix 2299", "prefix free"] parameters = [ - {"name": "prefix", "description": "Two-character prefix (e.g., 1A, 2B)"}, - {"name": "free", "description": "Show available/unused prefixes"} + {"name": "prefix", "description": "Prefix in hex (2 chars or configured length)"}, + {"name": "free", "description": "Show available/unused prefixes (may be disabled)"}, ] def __init__(self, bot: Any): @@ -252,17 +252,18 @@ class PrefixCommand(BaseCommand): """ try: # Query all repeaters with valid coordinates - query = ''' - SELECT SUBSTR(public_key, 1, 2) as prefix, public_key, name, - latitude, longitude, - COALESCE(last_advert_timestamp, last_heard) as last_seen - FROM complete_contact_tracking - WHERE role IN ('repeater', 'roomserver') - AND latitude IS NOT NULL - AND longitude IS NOT NULL - AND latitude != 0 - AND longitude != 0 - ''' + n = int(getattr(self.bot, "prefix_hex_chars", 2)) + query = f""" + SELECT SUBSTR(public_key, 1, {n}) AS prefix, + COUNT(*) AS repeater_count, + AVG(latitude) AS avg_lat, + AVG(longitude) AS avg_lon, + MAX(COALESCE(last_advert_timestamp, last_heard)) AS most_recent + FROM complete_contact_tracking + WHERE role IN ('repeater', 'roomserver') + AND LENGTH(public_key) >= {n} + GROUP BY prefix + """ results = self.bot.db_manager.execute_query(query) @@ -380,17 +381,18 @@ class PrefixCommand(BaseCommand): """ try: # Get all known prefixes from database - query = ''' - SELECT SUBSTR(public_key, 1, 2) as prefix, - COUNT(*) as repeater_count, - AVG(latitude) as avg_lat, - AVG(longitude) as avg_lon, - MAX(COALESCE(last_advert_timestamp, last_heard)) as most_recent - FROM complete_contact_tracking - WHERE role IN ('repeater', 'roomserver') - AND LENGTH(public_key) >= 2 - GROUP BY prefix - ''' + n = int(getattr(self.bot, "prefix_hex_chars", 2)) + query = f""" + SELECT SUBSTR(public_key, 1, {n}) AS prefix, + COUNT(*) AS repeater_count, + AVG(latitude) AS avg_lat, + AVG(longitude) AS avg_lon, + MAX(COALESCE(last_advert_timestamp, last_heard)) AS most_recent + FROM complete_contact_tracking + WHERE role IN ('repeater', 'roomserver') + AND LENGTH(public_key) >= {n} + GROUP BY prefix + """ results = self.bot.db_manager.execute_query(query) @@ -451,8 +453,9 @@ class PrefixCommand(BaseCommand): # Also include free prefixes (not in database) that aren't neighbors or excluded # Generate all valid hex prefixes (01-FE, excluding 00 and FF) - for i in range(1, 255): # 1 to 254 (exclude 0 and 255) - prefix = f"{i:02X}" + max_val = (16 ** self.bot.prefix_hex_chars) + for i in range(1, max_val - 1): # still excluding all-zeros and all-FF..FF + prefix = f"{i:0{self.bot.prefix_hex_chars}X}" prefix_lower = prefix.lower() # Skip if already in database (already processed above) @@ -803,6 +806,11 @@ class PrefixCommand(BaseCommand): # Handle free/available command if command == "FREE" or command == "AVAILABLE": + if getattr(self.bot, "prefix_hex_chars", 2) > 2: + # Keep behavior consistent: send a response and return True + await self._send_prefix_response(message, "Feature disabled for multi-byte prefixes.") + return True + free_prefixes, total_free, has_data = await self.get_free_prefixes() if not has_data: response = self.translate('commands.prefix.unable_determine_free') @@ -839,10 +847,22 @@ class PrefixCommand(BaseCommand): if len(parts) >= 3 and parts[2].upper() == "ALL": include_all = True - # Validate prefix format - if len(command) != 2 or not command.isalnum(): - response = self.translate('commands.prefix.invalid_format') - return await self.send_response(message, response) + # Validate prefix format: + # - allow legacy 2-char prefixes (current mesh hop IDs) + # - allow configured N-char prefixes (e.g., 4) for pubkey-prefix lookups + n = int(getattr(self.bot, "prefix_hex_chars", 2)) + allowed_lengths = {2, n} + + if len(command) not in allowed_lengths: + # If you updated translations to mention {{prefix_hex_chars}}, great, + # but this is clearer during the transition: + response = f"Invalid prefix format. Expected 2 or {n} hex characters." + return await self.send_response(message, response) + + import re + if not re.fullmatch(r"[0-9a-fA-F]+", command): + response = f"Invalid prefix format. Expected 2 or {n} hex characters." + return await self.send_response(message, response) # Get prefix data prefix_data = await self.get_prefix_data(command, include_all=include_all) @@ -1055,7 +1075,6 @@ class PrefixCommand(BaseCommand): AND last_heard >= datetime('now', '-{self.prefix_heard_days} days') ORDER BY name ''' - # The prefix should match the first two characters of the public key prefix_pattern = f"{prefix}%" @@ -1190,23 +1209,29 @@ class PrefixCommand(BaseCommand): # When using database, use prefix_free_days to filter which prefixes are considered "used" # Only repeaters heard within prefix_free_days will be considered as using a prefix try: + # If distance filtering is enabled, we need location data to filter + n = int(getattr(self.bot, "prefix_hex_chars", 2)) + # If distance filtering is enabled, we need location data to filter if self.distance_filtering_enabled: query = f''' - SELECT DISTINCT SUBSTR(public_key, 1, 2) as prefix, latitude, longitude - FROM complete_contact_tracking - WHERE role IN ('repeater', 'roomserver') - AND LENGTH(public_key) >= 2 - AND last_heard >= datetime('now', '-{self.prefix_free_days} days') + SELECT DISTINCT SUBSTR(public_key, 1, {n}) as prefix, + latitude, + longitude + FROM complete_contact_tracking + WHERE role IN ('repeater', 'roomserver') + AND LENGTH(public_key) >= {n} + AND last_heard >= datetime('now', '-{self.prefix_free_days} days') ''' else: query = f''' - SELECT DISTINCT SUBSTR(public_key, 1, 2) as prefix - FROM complete_contact_tracking - WHERE role IN ('repeater', 'roomserver') - AND LENGTH(public_key) >= 2 - AND last_heard >= datetime('now', '-{self.prefix_free_days} days') + SELECT DISTINCT SUBSTR(public_key, 1, {n}) as prefix + FROM complete_contact_tracking + WHERE role IN ('repeater', 'roomserver') + AND LENGTH(public_key) >= {n} + AND last_heard >= datetime('now', '-{self.prefix_free_days} days') ''' + results = self.bot.db_manager.execute_query(query) for row in results: prefix = row['prefix'].upper() @@ -1236,10 +1261,12 @@ class PrefixCommand(BaseCommand): self.logger.warning("No data available for free prefixes lookup (empty cache and database)") return [], 0, False - # Generate all valid hex prefixes (01-FE, excluding 00 and FF) + # Generate all valid hex prefixes (exclude all-zeros and all-FF) all_prefixes = [] - for i in range(1, 255): # 1 to 254 (exclude 0 and 255) - prefix = f"{i:02X}" + max_val = 16 ** self.bot.prefix_hex_chars + + for i in range(1, max_val - 1): + prefix = f"{i:0{self.bot.prefix_hex_chars}X}" all_prefixes.append(prefix) # Find free prefixes diff --git a/modules/core.py b/modules/core.py index 626e0c5..0b9ef0e 100644 --- a/modules/core.py +++ b/modules/core.py @@ -79,6 +79,11 @@ class MeshCoreBot: except (OSError, ValueError, sqlite3.Error) as e: self.logger.error(f"Failed to initialize database manager: {e}") raise + + # Set length of prefix + self.prefix_bytes = self.config.getint("Bot", "prefix_bytes", fallback=1) + self.prefix_hex_chars = self.prefix_bytes * 2 + self.logger.info(f"Prefix mode: {self.prefix_bytes} bytes ({self.prefix_hex_chars} hex chars)") # Store start time in database for web viewer access try: @@ -1532,3 +1537,9 @@ long_jokes = false self.logger.error(f"Error sending startup advert: {e}") import traceback self.logger.error(traceback.format_exc()) + + def key_prefix(self, public_key: str) -> str: + return public_key[:self.prefix_hex_chars] + + def is_valid_prefix(self, prefix: str) -> bool: + return len(prefix) == self.prefix_hex_chars diff --git a/modules/mesh_graph.py b/modules/mesh_graph.py index d69ba5a..fab3aea 100644 --- a/modules/mesh_graph.py +++ b/modules/mesh_graph.py @@ -167,9 +167,9 @@ class MeshGraph: if not self.capture_enabled: return - # Normalize prefixes to lowercase (2-char keys; 2-byte trace only sets confirmed_2byte flag) - from_prefix = from_prefix.lower()[:2] - to_prefix = to_prefix.lower()[:2] + # Normalize prefixes to lowercase + from_prefix = from_prefix.lower()[:self.bot.prefix_hex_chars] + to_prefix = to_prefix.lower()[:self.bot.prefix_hex_chars] # Intern public key strings so repeated identical keys share one object in RAM if from_public_key: @@ -826,8 +826,8 @@ class MeshGraph: Returns: bool: True if edge exists. """ - from_prefix = from_prefix.lower()[:2] - to_prefix = to_prefix.lower()[:2] + from_prefix = from_prefix.lower()[:self.bot.prefix_hex_chars] + to_prefix = to_prefix.lower()[:self.bot.prefix_hex_chars] return (from_prefix, to_prefix) in self.edges def get_edge(self, from_prefix: str, to_prefix: str) -> Optional[Dict]: @@ -840,8 +840,8 @@ class MeshGraph: Returns: Dict with edge data or None if not found. """ - from_prefix = from_prefix.lower()[:2] - to_prefix = to_prefix.lower()[:2] + from_prefix = from_prefix.lower()[:self.bot.prefix_hex_chars] + to_prefix = to_prefix.lower()[:self.bot.prefix_hex_chars] return self.edges.get((from_prefix, to_prefix)) def get_outgoing_edges(self, prefix: str) -> List[Dict]: @@ -855,7 +855,7 @@ class MeshGraph: Returns: List of edge dictionaries. """ - prefix = prefix.lower()[:2] + prefix = prefix.lower()[:self.bot.prefix_hex_chars] to_prefixes = self._outgoing_index.get(prefix) if not to_prefixes: return [] @@ -877,7 +877,7 @@ class MeshGraph: Returns: List of edge dictionaries. """ - prefix = prefix.lower()[:2] + prefix = prefix.lower()[:self.bot.prefix_hex_chars] from_prefixes = self._incoming_index.get(prefix) if not from_prefixes: return [] @@ -1081,8 +1081,8 @@ class MeshGraph: List of (candidate_prefix, score) tuples sorted by score (highest first). Score is 0.0-1.0 based on path strength. """ - from_prefix = from_prefix.lower()[:2] - to_prefix = to_prefix.lower()[:2] + from_prefix = from_prefix.lower()[:self.bot.prefix_hex_chars] + to_prefix = to_prefix.lower()[:self.bot.prefix_hex_chars] candidates: Dict[str, float] = {} diff --git a/modules/message_handler.py b/modules/message_handler.py index 8d3d892..87435d6 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -2052,7 +2052,7 @@ class MessageHandler: self.logger.debug("Mesh graph: No public key in advert data, skipping graph update") return - advertiser_prefix = advertiser_key[:2].lower() + advertiser_prefix = advertiser_key[:self.bot.prefix_hex_chars].lower() # Parse path from hex string path_nodes = [] @@ -2623,13 +2623,43 @@ class MessageHandler: ) except Exception as e: self.logger.debug(f"Failed to capture keyword data for web viewer: {e}") - + # Only execute commands if no help response was sent and no plugin command with response was matched # Help responses and plugin commands with responses should be the final response for that message # Plugin commands without responses (response is None) should still be executed if not help_response_sent and not plugin_command_with_response_matched: - await self.bot.command_manager.execute_commands(message) - + # After keyword handling, try RandomLine + randomline_match = self.bot.command_manager.match_randomline(message) + if randomline_match: + key, response = randomline_match + plugin_command_with_response_matched = True + import time + command_id = f"randomline_{key}_{message.sender_id}_{int(time.time())}" + + try: + rate_limit_key = self.bot.command_manager.get_rate_limit_key(message) + if message.is_dm: + success = await self.bot.command_manager.send_dm( + message.sender_id, response, command_id, rate_limit_key=rate_limit_key + ) + else: + success = await self.bot.command_manager.send_channel_message( + message.channel, response, command_id, rate_limit_key=rate_limit_key + ) + + if not success: + self.logger.warning( + f"Failed to send randomline response for '{key}' to " + f"{message.sender_id if message.is_dm else message.channel}" + ) + except Exception as e: + self.logger.error(f"Error sending randomline response for '{key}': {e}", exc_info=True) + success = False + + else: + # If no keyword or RandomLine match, try all other commands + await self.bot.command_manager.execute_commands(message) + def should_process_message(self, message: MeshMessage) -> bool: """Check if message should be processed by the bot""" # Check if bot is enabled @@ -2780,7 +2810,7 @@ class MessageHandler: try: node_data = { 'public_key': public_key, - 'prefix': public_key[:2].lower() if public_key else '', + 'prefix': public_key[:self.bot.prefix_hex_chars].lower() if public_key else '', 'name': contact_name, 'role': 'repeater' } diff --git a/modules/transmission_tracker.py b/modules/transmission_tracker.py index 0dcede5..d4acb96 100644 --- a/modules/transmission_tracker.py +++ b/modules/transmission_tracker.py @@ -59,7 +59,7 @@ class TransmissionTracker: if hasattr(device_info, 'public_key'): pubkey = device_info.public_key if isinstance(pubkey, str) and len(pubkey) >= 2: - self.bot_prefix = pubkey[:2].lower() + self.bot_prefix = pubkey[:self.bot.prefix_hex_chars].lower() elif isinstance(pubkey, bytes) and len(pubkey) >= 1: self.bot_prefix = f"{pubkey[0]:02x}".lower() self.logger.debug(f"Bot prefix set to: {self.bot_prefix}") @@ -300,7 +300,7 @@ class TransmissionTracker: last_node = path_nodes[-1] if isinstance(last_node, str) and len(last_node) >= 2: # Take first 2 characters as prefix - prefix = last_node[:2].lower() + prefix = last_node[:self.bot.prefix_hex_chars].lower() # Filter out our own prefix if prefix != self.bot_prefix: return [prefix] @@ -318,7 +318,7 @@ class TransmissionTracker: if parts: last_part = parts[-1] if len(last_part) >= 2: - prefix = last_part[:2].lower() + prefix = last_part[:self.bot.prefix_hex_chars].lower() # Filter out our own prefix if prefix != self.bot_prefix: return [prefix] diff --git a/modules/utils.py b/modules/utils.py index 0b3a70f..a8a98cf 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -1610,8 +1610,8 @@ def parse_path_string(path_str: str) -> List[str]: # Replace common separators with spaces path_str = path_str.replace(',', ' ').replace(':', ' ') - # Extract hex values using regex (2-character hex pairs) - hex_pattern = r'[0-9a-fA-F]{2}' + # Extract hex values using regex (prefix_hex_chars-wide hex tokens) + hex_pattern = rf'[0-9a-fA-F]{{{prefix_hex_chars}}}' hex_matches = re.findall(hex_pattern, path_str) # Convert to uppercase for consistency diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 1410be7..07ba82b 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -370,7 +370,7 @@ class BotDataViewer: else: # Space/comma-separated format path_input = path_input.replace(',', ' ').replace(':', ' ') - hex_pattern = r'[0-9a-fA-F]{2}' + hex_pattern = rf'[0-9a-fA-F]{{{prefix_hex_chars}}}' hex_matches = re.findall(hex_pattern, path_input) if not hex_matches: @@ -675,7 +675,7 @@ class BotDataViewer: best_method = None for repeater in repeaters: - candidate_prefix = repeater.get('public_key', '')[:2].lower() if repeater.get('public_key') else None + candidate_prefix = repeater.get('public_key', '')[:self.bot.prefix_hex_chars].lower() if repeater.get('public_key') else None candidate_public_key = repeater.get('public_key', '').lower() if repeater.get('public_key') else None if not candidate_prefix: continue @@ -1137,7 +1137,10 @@ class BotDataViewer: @self.app.route('/mesh') def mesh(): """Mesh graph visualization page""" - return render_template('mesh.html') + return render_template( + 'mesh.html', + prefix_hex_chars=self.bot.prefix_hex_chars + ) # Favicon routes @self.app.route('/apple-touch-icon.png') @@ -5176,7 +5179,7 @@ class BotDataViewer: else: # Space/comma-separated format path_input = path_hex.replace(',', ' ').replace(':', ' ') - hex_pattern = r'[0-9a-fA-F]{2}' + hex_pattern = rf'[0-9a-fA-F]{{{prefix_hex_chars}}}' hex_matches = re.findall(hex_pattern, path_input) if not hex_matches: @@ -5319,7 +5322,7 @@ class BotDataViewer: best_method = None for repeater in repeaters: - candidate_prefix = repeater.get('public_key', '')[:2].lower() if repeater.get('public_key') else None + candidate_prefix = repeater.get('public_key', '')[:self.bot.prefix_hex_chars].lower() if repeater.get('public_key') else None candidate_public_key = repeater.get('public_key', '').lower() if repeater.get('public_key') else None if not candidate_prefix: continue diff --git a/modules/web_viewer/templates/mesh.html b/modules/web_viewer/templates/mesh.html index 55846b7..a31f58f 100644 --- a/modules/web_viewer/templates/mesh.html +++ b/modules/web_viewer/templates/mesh.html @@ -378,35 +378,45 @@ let isInitialMapLoad = true; // Track if this is the first time rendering the map with nodes let highlightedPath = null; // Currently highlighted path data let pathHighlightTimeout = null; // Debounce timer for path resolution - + + const PREFIX_HEX_CHARS = {{ prefix_hex_chars|default(2) }}; + // Helper function to create unique node identifier function getNodeId(node) { return `${node.prefix}-${node.latitude.toFixed(6)}-${node.longitude.toFixed(6)}`; } - + // Detect if input is a hex path (2+ hex values) - function detectPathInput(input) { - if (!input || input.trim().length === 0) return false; - - // Normalize input: remove commas, spaces, colons - const normalized = input.replace(/[,\s:]/g, ''); - - // Check if it's a continuous hex string (e.g., "8601a5") - // If it's all hex and has 4+ characters (2+ hex pairs), it's a path - if (/^[0-9a-fA-F]{4,}$/.test(normalized)) { - return true; - } - - // Also check for space-separated hex values - const hexPattern = /\b[0-9a-fA-F]{2}\b/g; - const matches = input.match(hexPattern); - - // If we have 2+ hex values, treat as path - return matches && matches.length >= 2; - } - - // Resolve path via API - async function resolvePath(pathInput) { + function detectPathInput(input) { + if (!input || input.trim().length === 0) return false; + + // Pull from template if you can, otherwise default. + // If you already have this elsewhere on the page, reuse it. + const prefixHexChars = PREFIX_HEX_CHARS; + + // Normalize input: remove commas, spaces, colons + const normalized = input.replace(/[,\s:]/g, ''); + + // Check if it's a continuous hex string (e.g., "8601a5" or "8601A58F02") + // If it's all hex and has at least 2 tokens, treat as path. + // Optional: require it to align on token boundaries to reduce false positives. + const minChars = prefixHexChars * 2; // 2+ tokens + if (new RegExp(`^[0-9a-fA-F]{${minChars},}$`).test(normalized)) { + // If you want to be slightly stricter (still minimal), uncomment: + // if (normalized.length % prefixHexChars === 0) return true; + return true; + } + + // Also check for space-separated hex values + const hexPattern = new RegExp(`\\b[0-9a-fA-F]{${prefixHexChars}}\\b`, 'g'); + const matches = input.match(hexPattern); + + // If we have 2+ hex values, treat as path + return matches && matches.length >= 2; + } + + // Resolve path via API + async function resolvePath(pathInput) { try { const response = await fetch('/api/mesh/resolve-path', { method: 'POST', diff --git a/tests/helpers.py b/tests/helpers.py index a5379ce..a19959a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -104,8 +104,8 @@ def create_test_edge( to_public_key = (to_prefix.lower() * 16)[:64] return { - 'from_prefix': from_prefix.lower()[:2], - 'to_prefix': to_prefix.lower()[:2], + 'from_prefix': from_prefix.lower()[:self.bot.prefix_hex_chars], + 'to_prefix': to_prefix.lower()[:self.bot.prefix_hex_chars], 'from_public_key': from_public_key, 'to_public_key': to_public_key, 'observation_count': observation_count, @@ -125,7 +125,7 @@ def create_test_path(node_ids: List[str]) -> List[str]: Returns: List of node IDs (normalized to lowercase) """ - return [node_id.lower()[:2] for node_id in node_ids] + return [node_id.lower()[:self.bot.prefix_hex_chars] for node_id in node_ids] def populate_test_graph(mesh_graph, edges: List[Dict[str, Any]]): @@ -145,7 +145,7 @@ def populate_test_graph(mesh_graph, edges: List[Dict[str, Any]]): geographic_distance=edge.get('geographic_distance') ) # Manually set observation_count and timestamps if needed - edge_key = (edge['from_prefix'].lower()[:2], edge['to_prefix'].lower()[:2]) + edge_key = (edge['from_prefix'].lower()[:self.bot.prefix_hex_chars], edge['to_prefix'].lower()[:self.bot.prefix_hex_chars]) if edge_key in mesh_graph.edges: if edge.get('observation_count', 1) > 1: mesh_graph.edges[edge_key]['observation_count'] = edge['observation_count'] diff --git a/tests/test_randomline.py b/tests/test_randomline.py new file mode 100644 index 0000000..d2acb61 --- /dev/null +++ b/tests/test_randomline.py @@ -0,0 +1,58 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from modules.command_manager import CommandManager + + +class TestRandomLine: + def test_match_randomline_exact_match_normalizes_spaces_and_case(self, mock_bot, tmp_path): + f = tmp_path / "momjoke.txt" + f.write_text("line one\n\nline two\n", encoding="utf-8") + + if not mock_bot.config.has_section("RandomLine"): + mock_bot.config.add_section("RandomLine") + mock_bot.config.set("RandomLine", "prefix.default", "") + mock_bot.config.set("RandomLine", "triggers.momjoke", "momjoke,mom joke") + mock_bot.config.set("RandomLine", "file.momjoke", str(f)) + mock_bot.config.set("RandomLine", "prefix.momjoke", "πŸ₯Έ") + + manager = CommandManager(mock_bot) + manager.command_prefix = "" + + msg = SimpleNamespace( + content=" MOM JOKE ", + is_dm=True, + sender_id="abc", + channel="general", + ) + + with patch("modules.command_manager.random.choice", return_value="line two"): + result = manager.match_randomline(msg) + + assert result is not None + key, response = result + assert key == "momjoke" + assert response == "πŸ₯Έ line two" + + def test_match_randomline_does_not_match_extra_words(self, mock_bot, tmp_path): + f = tmp_path / "funfacts.txt" + f.write_text("fact one\n", encoding="utf-8") + + if not mock_bot.config.has_section("RandomLine"): + mock_bot.config.add_section("RandomLine") + mock_bot.config.set("RandomLine", "prefix.default", "") + mock_bot.config.set("RandomLine", "triggers.funfact", "funfact,fun fact") + mock_bot.config.set("RandomLine", "file.funfact", str(f)) + mock_bot.config.set("RandomLine", "prefix.funfact", "πŸ’‘") + + manager = CommandManager(mock_bot) + manager.command_prefix = "" + + msg = SimpleNamespace( + content="fun fact please", + is_dm=True, + sender_id="abc", + channel="general", + ) + + assert manager.match_randomline(msg) is None diff --git a/translations/en.json b/translations/en.json index 0e93c6b..3f9f0a6 100644 --- a/translations/en.json +++ b/translations/en.json @@ -246,7 +246,7 @@ "path": { "description": "Decode hex path data to show which repeaters were involved in message routing", "help": "Path: path [hex] - Decode path to show repeaters. Use path alone for current message path, or path [7e,01] for specific path.", - "no_valid_hex": "❌ No valid hex values found in path data. Use format like: 11,98,a4,49,cd,5f,01", + "no_valid_hex": "❌ No valid hex values found in path data.", "no_path": "❌ No path information available in current message", "error": "Error processing path: {error}", "error_decoding": "❌ Error decoding path: {error}", @@ -278,7 +278,7 @@ "refresh_not_available": "❌ Refresh not available - no API URL configured. Using local database only.", "cache_refreshed": "πŸ”„ Repeater prefix cache refreshed!", "unable_determine_free": "❌ Unable to determine free prefixes. Try 'prefix refresh' first.", - "invalid_format": "❌ Invalid prefix format. Use two characters (e.g., prefix 1A)", + "invalid_format": "❌ Invalid prefix format. Expected {prefix_hex_chars} hex characters.", "no_repeaters_found": "❌ No repeaters found with prefix '{prefix}'", "no_free_prefixes": "❌ No free prefixes found (all 254 valid prefixes are in use)", "available_prefixes": "Available Prefixes ({shown} of {total} free):",