mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-03 17:29:42 +00:00
Merge pull request #77 from ianrifkin/prefixes
Add transitional support for 2-byte prefixes (while keeping legacy compatibility)
This commit is contained in:
@@ -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.<key> = csv list of trigger words
|
||||
# file.<key> = path to text file
|
||||
# prefix.<key> = 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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
+111
-1
@@ -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.<key> = 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <XX|free|refresh>"
|
||||
examples = ["prefix 1A", "prefix free"]
|
||||
short_description = "Look up repeaters by prefix and show their locations (if known)"
|
||||
usage = "prefix <XX|XXXX|free|refresh>"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-11
@@ -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] = {}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
+4
-4
@@ -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']
|
||||
|
||||
@@ -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
|
||||
@@ -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):",
|
||||
|
||||
Reference in New Issue
Block a user