Update README and enhance command functionality

- Updated README to specify submitting pull requests against the dev branch.
- Added per-trigger lockout tracking in AnnouncementsCommand to prevent duplicate sends within a 60-second window.
- Implemented dynamic maximum message length calculation in BaseCommand for better message formatting.
- Enhanced response handling in PrefixCommand to support message splitting based on calculated length.
This commit is contained in:
agessaman
2025-12-31 16:54:38 -08:00
parent dfbf9c04a4
commit e4e70930f9
5 changed files with 186 additions and 8 deletions
+1 -1
View File
@@ -284,7 +284,7 @@ class MyCommand(BaseCommand):
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
4. Submit a pull request against the dev branch
## License
+28 -1
View File
@@ -27,6 +27,11 @@ class AnnouncementsCommand(BaseCommand):
# Per-trigger cooldown tracking: trigger_name -> last_execution_time
self.trigger_cooldowns: Dict[str, float] = {}
# Per-trigger lockout tracking: trigger_name -> last_send_time
# Prevents duplicate sends from retried DMs (60 second lockout)
self.trigger_lockouts: Dict[str, float] = {}
self.lockout_seconds = 60 # 60 second lockout to prevent duplicate sends
# Load configuration
self.enabled = self.get_config_value('Announcements_Command', 'enabled', fallback=False, value_type='bool')
self.default_channel = self.get_config_value('Announcements_Command', 'default_announcement_channel', fallback='Public', value_type='str')
@@ -172,7 +177,20 @@ class AnnouncementsCommand(BaseCommand):
def _record_trigger_execution(self, trigger_name: str):
"""Record the execution time for a trigger"""
self.trigger_cooldowns[trigger_name] = time.time()
current_time = time.time()
self.trigger_cooldowns[trigger_name] = current_time
self.trigger_lockouts[trigger_name] = current_time
def _is_trigger_locked(self, trigger_name: str) -> bool:
"""Check if a trigger is currently locked (within 60 seconds of last send)"""
if trigger_name not in self.trigger_lockouts:
return False
current_time = time.time()
last_send = self.trigger_lockouts[trigger_name]
elapsed = current_time - last_send
return elapsed < self.lockout_seconds
def _parse_command(self, content: str) -> tuple:
"""
@@ -252,6 +270,15 @@ class AnnouncementsCommand(BaseCommand):
)
return True
# Check lockout (applies even with override - prevents duplicate sends from retries)
if self._is_trigger_locked(trigger_name):
remaining_seconds = int(self.lockout_seconds - (time.time() - self.trigger_lockouts[trigger_name]))
await self.send_response(
message,
f"That announcement was just sent. Please wait {remaining_seconds} seconds to prevent duplicate sends."
)
return True
# Check cooldown (unless override)
if not is_override:
remaining_minutes = self._get_trigger_cooldown_remaining(trigger_name)
+48
View File
@@ -257,6 +257,54 @@ class BaseCommand(ABC):
self.logger.error(f"Failed to send response: {e}")
return False
def get_max_message_length(self, message: MeshMessage) -> int:
"""
Calculate the maximum message length dynamically based on message type and bot username.
Channel messages are formatted as "<username>: <message>", so:
max_length = 150 - username_length - 2 (for ": ")
DM messages don't have a username prefix, so:
max_length = 150
Args:
message: The MeshMessage to calculate max length for
Returns:
Maximum message length in characters
"""
# For DMs, no username prefix - full 150 characters available
if message.is_dm:
return 150
# For channel messages, calculate based on bot username length
# Try to get device username from meshcore first (actual radio username)
username = None
if hasattr(self.bot, 'meshcore') and self.bot.meshcore:
try:
if hasattr(self.bot.meshcore, 'self_info') and self.bot.meshcore.self_info:
self_info = self.bot.meshcore.self_info
# Try to get name from self_info (could be dict or object)
if isinstance(self_info, dict):
username = self_info.get('name') or self_info.get('user_name')
elif hasattr(self_info, 'name'):
username = self_info.name
elif hasattr(self_info, 'user_name'):
username = self_info.user_name
except Exception as e:
self.logger.debug(f"Could not get username from meshcore.self_info: {e}")
# Fall back to bot_name from config if device username not available
if not username:
username = self.bot.config.get('Bot', 'bot_name', fallback='Bot')
# Calculate max length: 150 - username_length - 2 (for ": ")
max_length = 150 - len(str(username)) - 2
# Ensure we don't return a negative or unreasonably small value
# Minimum of 130 characters to ensure some functionality
return max(130, max_length)
def _record_execution(self):
"""Record the execution time for cooldown tracking"""
import time
+7 -4
View File
@@ -1037,7 +1037,7 @@ class PathCommand(BaseCommand):
return None, 0.0
def _format_path_response(self, node_ids: List[str], repeater_info: Dict[str, Dict[str, Any]]) -> str:
"""Format the path decode response (max 130 chars per line)
"""Format the path decode response
Maintains the order of repeaters as they appear in the path (first to last)
"""
@@ -1103,7 +1103,10 @@ class PathCommand(BaseCommand):
# This ensures capture_command gets the full response, not just the last split message
self.last_response = response
if len(response) <= 130:
# Get dynamic max message length based on message type and bot username
max_length = self.get_max_message_length(message)
if len(response) <= max_length:
# Single message is fine
await self.send_response(message, response)
else:
@@ -1114,8 +1117,8 @@ class PathCommand(BaseCommand):
message_count = 0
for i, line in enumerate(lines):
# Check if adding this line would exceed 130 characters
if len(current_message) + len(line) + 1 > 130: # +1 for newline
# Check if adding this line would exceed max_length characters
if len(current_message) + len(line) + 1 > max_length: # +1 for newline
# Send current message and start new one
if current_message:
# Add ellipsis on new line to end of continued message (if not the last message)
+102 -2
View File
@@ -110,9 +110,11 @@ class PrefixCommand(BaseCommand):
free_prefixes, total_free, has_data = await self.get_free_prefixes()
if not has_data:
response = self.translate('commands.prefix.unable_determine_free')
return await self.send_response(message, response)
else:
response = self.format_free_prefixes_response(free_prefixes, total_free)
return await self.send_response(message, response)
await self._send_prefix_response(message, response)
return True
# Check for "all" modifier
include_all = False
@@ -136,7 +138,8 @@ class PrefixCommand(BaseCommand):
# Format response
response = self.format_prefix_response(command, prefix_data)
return await self.send_response(message, response)
await self._send_prefix_response(message, response)
return True
async def get_prefix_data(self, prefix: str, include_all: bool = False) -> Optional[Dict[str, Any]]:
"""Get prefix data from API first, enhanced with local database location data
@@ -614,6 +617,103 @@ class PrefixCommand(BaseCommand):
return response
async def _send_prefix_response(self, message: MeshMessage, response: str):
"""Send prefix response, splitting into multiple messages if necessary"""
# Store the complete response for web viewer integration BEFORE splitting
# command_manager will prioritize command.last_response over _last_response
# This ensures capture_command gets the full response, not just the last split message
self.last_response = response
# Get dynamic max message length based on message type and bot username
max_length = self.get_max_message_length(message)
if len(response) <= max_length:
# Single message is fine
await self.send_response(message, response)
else:
# Split into multiple messages for over-the-air transmission
# But keep the full response in last_response for web viewer
lines = response.split('\n')
# Calculate continuation markers length for planning
continuation_end = self.translate('commands.path.continuation_end')
continuation_start_template = self.translate('commands.path.continuation_start', line='PLACEHOLDER')
# Estimate continuation overhead (account for variable line length in template)
continuation_overhead = len(continuation_end) + len(continuation_start_template) - len('PLACEHOLDER')
# Estimate how many messages we'll need based on total content
total_content_length = sum(len(line) for line in lines) + (len(lines) - 1) # +1 for each newline between lines
# Account for continuation markers in multi-message scenarios
estimated_messages = max(2, (total_content_length + continuation_overhead * 2) // max(max_length - continuation_overhead, 1) + 1)
target_lines_per_message = max(1, (len(lines) + estimated_messages - 1) // estimated_messages) # Ceiling division
current_message = ""
message_count = 0
lines_in_current = 0
for i, line in enumerate(lines):
# Calculate if adding this line would exceed max_length
test_message = current_message
if test_message:
test_message += f"\n{line}"
else:
test_message = line
# Determine if we should split
must_split = len(test_message) > max_length
# Smart splitting: try to balance lines across messages
# Calculate remaining lines and messages for balancing
remaining_lines = len(lines) - i - 1 # Lines after current one
remaining_messages = estimated_messages - message_count - 1 # Messages after current one
# For first message, be more aggressive about fitting lines (prioritize earlier messages)
# For subsequent messages, balance more evenly
if message_count == 0:
# First message: try to fit more lines, only split if we must
# Be very conservative about splitting the first message - only if we absolutely must
# or if we're way over target and have plenty of room left
first_message_target = max(target_lines_per_message, 3) # At least 3 lines in first message if possible
should_balance_split = (
lines_in_current >= first_message_target and # We've hit minimum target for first message
remaining_lines > 0 and # There are more lines
remaining_messages > 0 and # There are more messages
len(test_message) > max_length * 0.95 # Very close to limit (95% threshold - be aggressive)
)
else:
# Subsequent messages: balance more evenly, but still try to fit reasonable amounts
should_balance_split = (
lines_in_current >= max(target_lines_per_message, 2) and # At least 2 lines per message
remaining_lines > 0 and # There are more lines
remaining_messages > 0 and # There are more messages
(remaining_lines >= remaining_messages or lines_in_current >= target_lines_per_message + 1) and # Can distribute or we've exceeded target
len(test_message) > max_length * 0.88 # Getting close to limit (88% threshold)
)
if (must_split or should_balance_split) and current_message:
# Send current message and start new one
# Add ellipsis on new line to end of continued message
current_message += continuation_end
await self.send_response(message, current_message.rstrip())
await asyncio.sleep(3.0) # Delay between messages (same as other commands)
message_count += 1
lines_in_current = 0
# Start new message with ellipsis on new line at beginning
current_message = self.translate('commands.path.continuation_start', line=line)
lines_in_current = 1
else:
# Add line to current message
if current_message:
current_message += f"\n{line}"
else:
current_message = line
lines_in_current += 1
# Send the last message if there's content
if current_message:
await self.send_response(message, current_message)
async def __aenter__(self):
"""Async context manager entry"""
return self