mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-07-20 18:41:06 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user