From e9913c578023525790fa4aabebe3eae8a1306a1d Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 23 Feb 2026 16:36:36 -0800 Subject: [PATCH 1/5] Add Earthquake Service configuration and documentation - Introduced `[Earthquake_Service]` section in `config.ini.example` to enable earthquake alerts with customizable parameters such as polling interval, time window, and minimum magnitude. - Updated `service-plugins.md` to include documentation for the new Earthquake Service, detailing its functionality and default settings. --- config.ini.example | 33 +++ docs/earthquake-service.md | 59 +++++ docs/service-plugins.md | 1 + modules/service_plugins/earthquake_service.py | 228 ++++++++++++++++++ 4 files changed, 321 insertions(+) create mode 100644 docs/earthquake-service.md create mode 100644 modules/service_plugins/earthquake_service.py diff --git a/config.ini.example b/config.ini.example index 0372282..ef8ec3a 100644 --- a/config.ini.example +++ b/config.ini.example @@ -1367,6 +1367,39 @@ blitz_collection_interval = 600000 # blitz_area_max_lat = 48.76 # blitz_area_max_lon = 18.62 +[Earthquake_Service] +# Enable earthquake alert service (true/false) +# Polls USGS Earthquake API and posts alerts to a channel when quakes occur in the configured region +enabled = false + +# Channel to post earthquake alerts to +channel = #general + +# Poll interval (milliseconds). How often to check USGS for new earthquakes +# Default: 60000 (1 minute) +poll_interval = 60000 + +# Time window (minutes). Only earthquakes in the last N minutes are queried +# Default: 10 +time_window_minutes = 10 + +# Minimum magnitude to report (e.g. 3.0 for M3.0+) +# Default: 3.0 +min_magnitude = 3.0 + +# Region bounding box (decimal degrees). Defaults are California +# Southern and northern latitude bounds +minlatitude = 32.5 +maxlatitude = 42.0 +# Western and eastern longitude bounds (negative = West) +minlongitude = -124.5 +maxlongitude = -114.0 + +# Send USGS event link in a separate message following the alert (true/false) +# When true: notification message then link-only message. When false: no link sent. +# Default: true +send_link = true + [DiscordBridge] # Enable Discord bridge service # Enable Discord bridge (true/false). One-way, read-only webhooks diff --git a/docs/earthquake-service.md b/docs/earthquake-service.md new file mode 100644 index 0000000..86e962b --- /dev/null +++ b/docs/earthquake-service.md @@ -0,0 +1,59 @@ +# Earthquake Service + +Polls the USGS Earthquake API and posts alerts to a channel when earthquakes occur in a configured region. Defaults to California (M3.0+, past 10 minutes). No API key required. + +--- + +## Quick Start + +1. **Configure Bot** - Edit `config.ini`: + +```ini +[Earthquake_Service] +enabled = true +channel = #general + +# Optional: adjust region or magnitude (defaults are California, M3.0+) +# minlatitude = 32.5 +# maxlatitude = 42.0 +# minlongitude = -124.5 +# maxlongitude = -114.0 +# min_magnitude = 3.0 +# time_window_minutes = 10 +# poll_interval = 60000 +``` + +2. **Restart Bot** - The service will start polling USGS and post to the channel when quakes are detected. + +--- + +## Configuration + +All options live under `[Earthquake_Service]`. See `config.ini.example` for the full list and comments. + +| Option | Description | Default | +|--------|-------------|---------| +| `enabled` | Turn the service on or off | `false` | +| `channel` | Mesh channel for earthquake alerts | `#general` | +| `poll_interval` | How often to check USGS (milliseconds) | `60000` (1 min) | +| `time_window_minutes` | Only consider quakes in the last N minutes | `10` | +| `min_magnitude` | Minimum magnitude to report | `3.0` | +| `minlatitude`, `maxlatitude` | Latitude bounds (decimal degrees) | 32.5, 42.0 (California) | +| `minlongitude`, `maxlongitude` | Longitude bounds (decimal degrees) | -124.5, -114.0 (California) | +| `send_link` | Send USGS event link in a separate message after the alert | `true` | + +--- + +## Features + +- **Polling**: Runs in the background and checks USGS at `poll_interval`. Uses the same [USGS FDSNWS Event API](https://earthquake.usgs.gov/fdsnws/event/1/) as the standalone California earthquake script. +- **Region**: Only earthquakes inside the configured bounding box (lat/lon) are reported. Defaults match California. +- **Magnitude filter**: Only events with magnitude β‰₯ `min_magnitude` are sent. +- **Deduplication**: Each event is sent once. In-memory seen event IDs avoid duplicates within a run. The last posted event time is stored in the `bot_metadata` table (`earthquake_last_posted_time`) so after a restart the bot skips events that were already posted. + +**Example alert (when `send_link = true`, two messages):** +``` +Earthquake M3.2 mb | 12km NW of Borrego Springs, CA | 14:32:15 UTC | depth 12 km | 33.28N 116.42W +https://earthquake.usgs.gov/earthquakes/eventpage/ci40623456 +``` +When `send_link = false`, only the first line is sent and no link is posted. diff --git a/docs/service-plugins.md b/docs/service-plugins.md index a33d464..f616af7 100644 --- a/docs/service-plugins.md +++ b/docs/service-plugins.md @@ -8,6 +8,7 @@ Service plugins extend the bot with background services that run alongside the m | [Packet Capture](packet-capture.md) | Capture packets from the mesh and publish them to MQTT brokers | | [Map Uploader](map-uploader.md) | Upload node advertisements to [map.meshcore.dev](https://map.meshcore.dev) for network visualization | | [Weather Service](weather-service.md) | Scheduled weather forecasts, weather alerts, and lightning detection | +| [Earthquake Service](earthquake-service.md) | Earthquake alerts for a configured region (USGS API, defaults: California) | ## Enabling a plugin diff --git a/modules/service_plugins/earthquake_service.py b/modules/service_plugins/earthquake_service.py new file mode 100644 index 0000000..3ad1639 --- /dev/null +++ b/modules/service_plugins/earthquake_service.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +Earthquake Alert Service for MeshCore Bot +Polls USGS Earthquake API and notifies a channel when earthquakes occur in a configured region. +""" + +import asyncio +from datetime import datetime, timezone, timedelta +from typing import Any, Optional, Set + +import requests + +from .base_service import BaseServicePlugin + +# California bounding box defaults (decimal degrees) +DEFAULT_MIN_LAT = 32.5 +DEFAULT_MAX_LAT = 42.0 +DEFAULT_MIN_LON = -124.5 +DEFAULT_MAX_LON = -114.0 +USGS_QUERY_URL = "https://earthquake.usgs.gov/fdsnws/event/1/query" +SEEN_IDS_MAX = 500 +METADATA_KEY_LAST_POSTED_TIME = "earthquake_last_posted_time" + + +class EarthquakeService(BaseServicePlugin): + """Service that polls USGS for earthquakes in a region and posts alerts to a channel.""" + + config_section = "Earthquake_Service" + description = "Earthquake alerts for a configured region (USGS API)" + + def __init__(self, bot: Any) -> None: + super().__init__(bot) + + section = "Earthquake_Service" + self.channel = self.bot.config.get(section, "channel", fallback="general") + poll_ms = self.bot.config.getint(section, "poll_interval", fallback=60000) + self.poll_interval_seconds = poll_ms / 1000.0 + self.time_window_minutes = self.bot.config.getint( + section, "time_window_minutes", fallback=10 + ) + self.min_magnitude = self.bot.config.getfloat( + section, "min_magnitude", fallback=3.0 + ) + self.minlatitude = self.bot.config.getfloat( + section, "minlatitude", fallback=DEFAULT_MIN_LAT + ) + self.maxlatitude = self.bot.config.getfloat( + section, "maxlatitude", fallback=DEFAULT_MAX_LAT + ) + self.minlongitude = self.bot.config.getfloat( + section, "minlongitude", fallback=DEFAULT_MIN_LON + ) + self.maxlongitude = self.bot.config.getfloat( + section, "maxlongitude", fallback=DEFAULT_MAX_LON + ) + self.send_link = self.bot.config.getboolean( + section, "send_link", fallback=True + ) + + self._running = False + self._poll_task: Optional[asyncio.Task] = None + self.seen_event_ids: Set[str] = set() + self._last_posted_time_ms: int = self._load_last_posted_time_ms() + self._session = requests.Session() + + self.logger.info( + "Earthquake service initialized: channel=%s, region lat %.1f–%.1f lon %.1f–%.1f, M>=%.1f", + self.channel, + self.minlatitude, + self.maxlatitude, + self.minlongitude, + self.maxlongitude, + self.min_magnitude, + ) + + async def start(self) -> None: + if not self.enabled: + self.logger.info("Earthquake service is disabled, not starting") + return + self._running = True + self.logger.info("Starting earthquake service") + self._poll_task = asyncio.create_task(self._poll_loop()) + self.logger.info("Earthquake service started") + + async def stop(self) -> None: + self._running = False + self.logger.info("Stopping earthquake service") + if self._poll_task: + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + self._session.close() + self.logger.info("Earthquake service stopped") + + def _load_last_posted_time_ms(self) -> int: + """Load last posted event time (ms) from bot_metadata to avoid reposts after restart.""" + if not getattr(self.bot, "db_manager", None): + return 0 + raw = self.bot.db_manager.get_metadata(METADATA_KEY_LAST_POSTED_TIME) + if not raw: + return 0 + try: + return int(raw) + except ValueError: + return 0 + + async def _poll_loop(self) -> None: + self.logger.info( + "Earthquake poll loop started (interval=%.1fs, window=%d min)", + self.poll_interval_seconds, + self.time_window_minutes, + ) + while self._running: + try: + await self._check_earthquakes() + await asyncio.sleep(self.poll_interval_seconds) + except asyncio.CancelledError: + break + except Exception as e: + self.logger.error("Error in earthquake poll loop: %s", e) + await asyncio.sleep(60) + + async def _check_earthquakes(self) -> None: + end_time = datetime.now(timezone.utc) + start_time = end_time - timedelta(minutes=self.time_window_minutes) + params = { + "format": "geojson", + "starttime": start_time.strftime("%Y-%m-%dT%H:%M:%S"), + "endtime": end_time.strftime("%Y-%m-%dT%H:%M:%S"), + "minmagnitude": self.min_magnitude, + "minlatitude": self.minlatitude, + "maxlatitude": self.maxlatitude, + "minlongitude": self.minlongitude, + "maxlongitude": self.maxlongitude, + "orderby": "magnitude", + } + + loop = asyncio.get_event_loop() + try: + response = await loop.run_in_executor( + None, + lambda: self._session.get(USGS_QUERY_URL, params=params, timeout=10), + ) + response.raise_for_status() + data = response.json() + except requests.exceptions.RequestException as e: + self.logger.warning("USGS request failed: %s", e) + return + except (ValueError, KeyError) as e: + self.logger.warning("USGS response parse error: %s", e) + return + + features = data.get("features", []) + max_posted_time_ms = self._last_posted_time_ms + for quake in features: + event_id = quake.get("id") + props = quake.get("properties", {}) + event_time_ms = props.get("time") or 0 + if not event_id or event_id in self.seen_event_ids: + continue + if event_time_ms <= self._last_posted_time_ms: + continue + + try: + text = self._format_quake(quake) + if text: + await self.bot.command_manager.send_channel_message( + self.channel, text + ) + url_detail = props.get("url", "") + if self.send_link and url_detail: + await self.bot.command_manager.send_channel_message( + self.channel, url_detail + ) + self.logger.info("Earthquake alert sent: %s", event_id) + self.seen_event_ids.add(event_id) + if event_time_ms > max_posted_time_ms: + max_posted_time_ms = event_time_ms + except Exception as e: + self.logger.error("Error sending earthquake alert: %s", e) + + if max_posted_time_ms > self._last_posted_time_ms and getattr( + self.bot, "db_manager", None + ): + self._last_posted_time_ms = max_posted_time_ms + self.bot.db_manager.set_metadata( + METADATA_KEY_LAST_POSTED_TIME, str(max_posted_time_ms) + ) + + if len(self.seen_event_ids) > SEEN_IDS_MAX: + self.seen_event_ids = set(list(self.seen_event_ids)[-SEEN_IDS_MAX:]) + + def _format_quake(self, quake: dict) -> str: + props = quake.get("properties", {}) + geometry = quake.get("geometry", {}) + coords = geometry.get("coordinates", []) + + mag = props.get("mag") + mag_type = props.get("magType", "") + place = props.get("place", "Unknown location") + depth = coords[2] if len(coords) > 2 else None + lon = coords[0] if len(coords) > 0 else None + lat = coords[1] if len(coords) > 1 else None + + quake_time_ms = props.get("time") + if quake_time_ms: + quake_time = datetime.fromtimestamp( + quake_time_ms / 1000, tz=timezone.utc + ) + time_str = quake_time.strftime("%H:%M:%S UTC") + else: + time_str = "Unknown" + + parts = ["Earthquake M%.1f" % (mag if mag is not None else 0)] + if mag_type: + parts[0] += " %s" % mag_type + parts.append(place) + parts.append(time_str) + if depth is not None: + parts.append("depth %s km" % (int(depth) if isinstance(depth, (int, float)) else depth)) + if lat is not None and lon is not None: + parts.append("%.2fN %.2fW" % (lat, abs(lon))) + + # When send_link is true the link is sent in a separate follow-up message + return " | ".join(str(p) for p in parts) From 99328d6dd748effe80fcb52b11bc4e7345eb25a0 Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 23 Feb 2026 16:41:25 -0800 Subject: [PATCH 2/5] Update earthquake-service documentation to include credit section --- docs/earthquake-service.md | 6 ++++++ 1 file changed, 6 insertions(+) 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). From 7671c3a8dec3d5f79cdbb4a57667b1b4a7847bb5 Mon Sep 17 00:00:00 2001 From: Ian Rifkin Date: Wed, 25 Feb 2026 00:06:09 -0500 Subject: [PATCH 3/5] Add RandomLine matcher for file-based trigger responses with default momjokes and fun facts --- config.ini.example | 19 +++++++ modules/command_manager.py | 112 ++++++++++++++++++++++++++++++++++++- modules/message_handler.py | 36 +++++++++++- tests/test_randomline.py | 58 +++++++++++++++++++ 4 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 tests/test_randomline.py diff --git a/config.ini.example b/config.ini.example index ef8ec3a..bd41b4e 100644 --- a/config.ini.example +++ b/config.ini.example @@ -285,6 +285,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/modules/command_manager.py b/modules/command_manager.py index b0eb198..b11de6c 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 @@ -628,7 +629,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/message_handler.py b/modules/message_handler.py index 07cdf55..eece506 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -2928,13 +2928,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 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 From a4174beddf372a0cc3d34e16b304cd025d5001ad Mon Sep 17 00:00:00 2001 From: Ian Rifkin Date: Wed, 25 Feb 2026 00:16:43 -0500 Subject: [PATCH 4/5] Adding example data files for momjokes and funfacts so it's a fully functioning thing --- data/randomlines/funfacts.txt | 4 ++++ data/randomlines/momjokes.txt | 3 +++ 2 files changed, 7 insertions(+) create mode 100644 data/randomlines/funfacts.txt create mode 100644 data/randomlines/momjokes.txt 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. From 36a8a675437a3a0da4812010bb71accd3eb5b037 Mon Sep 17 00:00:00 2001 From: Ian Rifkin Date: Fri, 27 Feb 2026 23:48:27 -0500 Subject: [PATCH 5/5] Add transitional support for 2-byte prefixes while keeping legacy 1-byte compatibility - Update prefix command to accept BOTH legacy 2-char prefixes and configured prefix_hex_chars (e.g. 4-char) during firmware transition - Replace strict length validation with dual-length validation (2 or N) - Ensure prefix lookups work with either input length via LIKE matching - Update related SQL prefix extraction to use configured prefix length - Add fallback handling in path parsing for legacy 2-char route data Notes: - This is an interim compatibility change to support mixed networks where RF path data is still 1-byte while bot config may be 2-byte. - Needs additional testing across real multi-hop scenarios and mixed bot configurations. - Translation updates are incomplete: only English strings were updated; other translation files still need review. - Behavior and UX may need refinement after real-world testing. --- config.ini.example | 3 + modules/commands/path_command.py | 17 +++- modules/commands/prefix_command.py | 125 +++++++++++++++---------- modules/core.py | 11 +++ modules/mesh_graph.py | 20 ++-- modules/message_handler.py | 4 +- modules/transmission_tracker.py | 6 +- modules/utils.py | 4 +- modules/web_viewer/app.py | 13 ++- modules/web_viewer/templates/mesh.html | 58 +++++++----- tests/helpers.py | 8 +- translations/en.json | 4 +- 12 files changed, 168 insertions(+), 105 deletions(-) diff --git a/config.ini.example b/config.ini.example index bd41b4e..da1cb75 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. 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 3833b1a..681e2e0 100644 --- a/modules/mesh_graph.py +++ b/modules/mesh_graph.py @@ -166,8 +166,8 @@ class MeshGraph: return # Normalize prefixes to lowercase - 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] # Intern public key strings so repeated identical keys share one object in RAM if from_public_key: @@ -797,8 +797,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]: @@ -811,8 +811,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]: @@ -826,7 +826,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 [] @@ -848,7 +848,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 [] @@ -1052,8 +1052,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 eece506..4a25e2a 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -2045,7 +2045,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 = [] @@ -3115,7 +3115,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 14f0463..7978f09 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -1567,8 +1567,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 ddd4305..f92d2de 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') @@ -5167,7 +5170,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: @@ -5310,7 +5313,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/translations/en.json b/translations/en.json index 77bce16..b8b3f7c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -245,7 +245,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}", @@ -273,7 +273,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):",