diff --git a/README.md b/README.md index 156e2ba..3aaef09 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A Python bot that connects to MeshCore mesh networks via serial port, BLE, or TC - **Connection Methods**: Serial port, BLE (Bluetooth Low Energy), or TCP/IP - **Keyword Responses**: Configurable keyword-response pairs with template variables - **Command System**: Plugin-based command architecture with built-in commands -- **Rate Limiting**: Configurable rate limiting to prevent network spam +- **Rate Limiting**: Global, per-user (by pubkey or name), and bot transmission rate limits to prevent spam - **User Management**: Ban/unban users with persistent storage - **Scheduled Messages**: Send messages at configured times - **Direct Message Support**: Respond to private messages @@ -164,7 +164,10 @@ timeout = 30 # Connection timeout [Bot] bot_name = MeshCoreBot # Bot identification name enabled = true # Enable/disable bot -rate_limit_seconds = 2 # Rate limiting interval +rate_limit_seconds = 2 # Global: min seconds between any bot reply +bot_tx_rate_limit_seconds = 1.0 # Min seconds between bot transmissions +per_user_rate_limit_seconds = 5 # Per-user: min seconds between replies to same user (pubkey or name) +per_user_rate_limit_enabled = true startup_advert = flood # Send advert on startup ``` @@ -333,7 +336,9 @@ help = "Bot Help: test, ping, help, hello, cmd, wx, gwx, aqi, sun, moon, solar, - Check meshcore library documentation for protocol details 5. **Rate Limiting**: - - Adjust `rate_limit_seconds` in config + - **Global**: `rate_limit_seconds` — minimum time between any two bot replies + - **Per-user**: `per_user_rate_limit_seconds` and `per_user_rate_limit_enabled` — minimum time between replies to the same user (user identified by public key when available, else sender name; channel senders often matched by name) + - **Bot TX**: `bot_tx_rate_limit_seconds` — minimum time between bot transmissions on the mesh - Check logs for rate limiting messages ### Debug Mode diff --git a/config.ini.example b/config.ini.example index 0ef05c0..5215316 100644 --- a/config.ini.example +++ b/config.ini.example @@ -67,6 +67,13 @@ rate_limit_seconds = 10 # Prevents bot from overwhelming the mesh network bot_tx_rate_limit_seconds = 1.0 +# Per-user rate limit: minimum seconds between bot replies to the same user +# User is identified by public key when available (DMs and channel when provided), else sender name +# Channel senders are often matched by name only. Set to 0 or disable to effectively turn off per-user limiting +per_user_rate_limit_seconds = 5 +# Enable or disable per-user rate limiting (true/false) +per_user_rate_limit_enabled = true + # Transmission delay in milliseconds before sending messages # Helps prevent message collisions on the mesh network # Recommended: 100-500ms for busy networks, 0 for quiet networks diff --git a/config.ini.minimal-example b/config.ini.minimal-example index 073d3af..0b313a4 100644 --- a/config.ini.minimal-example +++ b/config.ini.minimal-example @@ -82,6 +82,11 @@ rate_limit_seconds = 10 # Prevents bot from overwhelming the mesh network bot_tx_rate_limit_seconds = 1.0 +# Per-user rate limit: minimum seconds between bot replies to the same user +# User key: public key when available, else sender name +per_user_rate_limit_seconds = 5 +per_user_rate_limit_enabled = true + # Transmission delay in milliseconds before sending messages # Helps prevent message collisions on the mesh network # Recommended: 100-500ms for busy networks, 0 for quiet networks diff --git a/modules/command_manager.py b/modules/command_manager.py index 80bc4d7..5414f21 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -254,7 +254,13 @@ class CommandManager: self.logger.debug(f"Applying {self.bot.tx_delay_ms}ms transmission delay") await asyncio.sleep(self.bot.tx_delay_ms / 1000.0) - async def _check_rate_limits(self, skip_user_rate_limit: bool = False) -> Tuple[bool, str]: + def get_rate_limit_key(self, message: MeshMessage) -> Optional[str]: + """Return the key used for per-user rate limiting (pubkey when available, else sender name).""" + return message.sender_pubkey or message.sender_id or None + + async def _check_rate_limits( + self, skip_user_rate_limit: bool = False, rate_limit_key: Optional[str] = None + ) -> Tuple[bool, str]: """Check all rate limits before sending. Checks both the user-specific rate limits and the global bot transmission @@ -262,21 +268,28 @@ class CommandManager: Args: skip_user_rate_limit: If True, skip the user rate limiter check (for automated responses). + rate_limit_key: Optional key for per-user rate limit (e.g. from get_rate_limit_key(message)). Returns: Tuple[bool, str]: A tuple containing: - can_send: True if the message can be sent, False otherwise. - reason: Reason string if rate limited, empty string otherwise. """ - # Check user rate limiter (unless skipped for automated responses) + # Check global user rate limiter (unless skipped for automated responses) if not skip_user_rate_limit: if not self.bot.rate_limiter.can_send(): wait_time = self.bot.rate_limiter.time_until_next() - # Only log warning if there's a meaningful wait time (> 0.1 seconds) - # This avoids misleading "Wait 0.0 seconds" messages from timing edge cases if wait_time > 0.1: return False, f"Rate limited. Wait {wait_time:.1f} seconds" - return False, "" # Still rate limited, just don't log for very short waits + return False, "" + # Per-user rate limit when enabled and key present + if getattr(self.bot, 'per_user_rate_limit_enabled', False) and rate_limit_key: + per_user = getattr(self.bot, 'per_user_rate_limiter', None) + if per_user and not per_user.can_send(rate_limit_key): + wait_time = per_user.time_until_next(rate_limit_key) + if wait_time > 0.1: + return False, f"Rate limited. Wait {wait_time:.1f} seconds" + return False, "" # Wait for bot TX rate limiter await self.bot.bot_tx_rate_limiter.wait_for_tx() @@ -286,7 +299,14 @@ class CommandManager: return True, "" - def _handle_send_result(self, result, operation_name: str, target: str, used_retry_method: bool = False) -> bool: + def _handle_send_result( + self, + result, + operation_name: str, + target: str, + used_retry_method: bool = False, + rate_limit_key: Optional[str] = None, + ) -> bool: """Handle result from message send operations. Args: @@ -294,6 +314,7 @@ class CommandManager: operation_name: Name of the operation ("DM" or "Channel message"). target: Recipient name or channel name for logging. used_retry_method: True if send_msg_with_retry was used (affects logging). + rate_limit_key: Optional key for per-user rate limit recording. Returns: bool: True if send succeeded (ACK received or sent successfully), False otherwise. @@ -318,6 +339,10 @@ class CommandManager: self.logger.info(f"✅ {operation_name} sent to {target}") self.bot.rate_limiter.record_send() self.bot.bot_tx_rate_limiter.record_tx() + if getattr(self.bot, 'per_user_rate_limit_enabled', False) and rate_limit_key: + per_user = getattr(self.bot, 'per_user_rate_limiter', None) + if per_user: + per_user.record_send(rate_limit_key) return True # Handle unexpected event types @@ -331,6 +356,10 @@ class CommandManager: self.logger.warning(f"Channel message sent to {target} but confirmation event not received (message may have been sent)") self.bot.rate_limiter.record_send() self.bot.bot_tx_rate_limiter.record_tx() + if getattr(self.bot, 'per_user_rate_limit_enabled', False) and rate_limit_key: + per_user = getattr(self.bot, 'per_user_rate_limiter', None) + if per_user: + per_user.record_send(rate_limit_key) return True # Unknown event type - log warning @@ -341,6 +370,10 @@ class CommandManager: self.logger.info(f"✅ {operation_name} sent to {target} (result: {result})") self.bot.rate_limiter.record_send() self.bot.bot_tx_rate_limiter.record_tx() + if getattr(self.bot, 'per_user_rate_limit_enabled', False) and rate_limit_key: + per_user = getattr(self.bot, 'per_user_rate_limiter', None) + if per_user: + per_user.record_send(rate_limit_key) return True def load_keywords(self) -> Dict[str, str]: @@ -619,7 +652,14 @@ class CommandManager: if stats_command: stats_command.record_command(message, 'advert', response_sent) - async def send_dm(self, recipient_id: str, content: str, command_id: Optional[str] = None, skip_user_rate_limit: bool = False) -> bool: + async def send_dm( + self, + recipient_id: str, + content: str, + command_id: Optional[str] = None, + skip_user_rate_limit: bool = False, + rate_limit_key: Optional[str] = None, + ) -> bool: """Send a direct message using meshcore-cli command. Handles contact lookup, rate limiting, and uses retry logic if available. @@ -628,6 +668,8 @@ class CommandManager: recipient_id: The recipient's name or ID. content: The message content to send. command_id: Optional command_id for repeat tracking (if not provided, one will be generated). + skip_user_rate_limit: If True, skip user rate limiter checks (for automated responses). + rate_limit_key: Optional key for per-user rate limiting (e.g. from get_rate_limit_key(message)). Returns: bool: True if sent successfully, False otherwise. @@ -636,7 +678,9 @@ class CommandManager: return False # Check all rate limits - can_send, reason = await self._check_rate_limits(skip_user_rate_limit=skip_user_rate_limit) + can_send, reason = await self._check_rate_limits( + skip_user_rate_limit=skip_user_rate_limit, rate_limit_key=rate_limit_key + ) if not can_send: if reason: self.logger.warning(reason) @@ -704,13 +748,22 @@ class CommandManager: hasattr(self.bot.meshcore.commands, 'send_msg_with_retry')) # Handle result using unified handler - return self._handle_send_result(result, "DM", contact_name, used_retry_method) + return self._handle_send_result( + result, "DM", contact_name, used_retry_method, rate_limit_key=rate_limit_key + ) except Exception as e: self.logger.error(f"Failed to send DM: {e}") return False - async def send_channel_message(self, channel: str, content: str, command_id: Optional[str] = None, skip_user_rate_limit: bool = False) -> bool: + async def send_channel_message( + self, + channel: str, + content: str, + command_id: Optional[str] = None, + skip_user_rate_limit: bool = False, + rate_limit_key: Optional[str] = None, + ) -> bool: """Send a channel message using meshcore-cli command. Resolves channel names to numbers and handles rate limiting. @@ -719,6 +772,8 @@ class CommandManager: channel: The channel name (e.g., "LongFast"). content: The message content to send. command_id: Optional command_id for repeat tracking (if not provided, one will be generated). + skip_user_rate_limit: If True, skip user rate limiter checks (for automated responses). + rate_limit_key: Optional key for per-user rate limiting (e.g. from get_rate_limit_key(message)). Returns: bool: True if sent successfully, False otherwise. @@ -727,7 +782,9 @@ class CommandManager: return False # Check all rate limits - can_send, reason = await self._check_rate_limits(skip_user_rate_limit=skip_user_rate_limit) + can_send, reason = await self._check_rate_limits( + skip_user_rate_limit=skip_user_rate_limit, rate_limit_key=rate_limit_key + ) if not can_send: if reason: self.logger.warning(reason) @@ -765,7 +822,9 @@ class CommandManager: # Handle result using unified handler target = f"{channel} (channel {channel_num})" - return self._handle_send_result(result, "Channel message", target) + return self._handle_send_result( + result, "Channel message", target, rate_limit_key=rate_limit_key + ) except Exception as e: self.logger.error(f"Failed to send channel message: {e}") @@ -928,10 +987,19 @@ class CommandManager: else: self._last_response = content + rate_limit_key = self.get_rate_limit_key(message) if message.is_dm: - return await self.send_dm(message.sender_id, content, skip_user_rate_limit=skip_user_rate_limit) + return await self.send_dm( + message.sender_id, content, + skip_user_rate_limit=skip_user_rate_limit, + rate_limit_key=rate_limit_key, + ) else: - return await self.send_channel_message(message.channel, content, skip_user_rate_limit=skip_user_rate_limit) + return await self.send_channel_message( + message.channel, content, + skip_user_rate_limit=skip_user_rate_limit, + rate_limit_key=rate_limit_key, + ) except Exception as e: self.logger.error(f"Failed to send response: {e}") return False diff --git a/modules/core.py b/modules/core.py index ab0303f..8c2e396 100644 --- a/modules/core.py +++ b/modules/core.py @@ -27,7 +27,7 @@ from meshcore import EventType from meshcore_cli.meshcore_cli import send_cmd, send_chan_msg # Import our modules -from .rate_limiter import RateLimiter, BotTxRateLimiter, NominatimRateLimiter +from .rate_limiter import RateLimiter, BotTxRateLimiter, PerUserRateLimiter, NominatimRateLimiter from .message_handler import MessageHandler from .command_manager import CommandManager from .channel_manager import ChannelManager @@ -118,6 +118,14 @@ class MeshCoreBot: self.bot_tx_rate_limiter = BotTxRateLimiter( self.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0) ) + # Per-user rate limiter: minimum seconds between replies to the same user (key = pubkey or name) + self.per_user_rate_limit_enabled = self.config.getboolean( + 'Bot', 'per_user_rate_limit_enabled', fallback=True + ) + self.per_user_rate_limiter = PerUserRateLimiter( + seconds=self.config.getfloat('Bot', 'per_user_rate_limit_seconds', fallback=5.0), + max_entries=1000 + ) # Nominatim rate limiter: 1.1 seconds between requests (Nominatim policy: max 1 req/sec) self.nominatim_rate_limiter = NominatimRateLimiter( self.config.getfloat('Bot', 'nominatim_rate_limit_seconds', fallback=1.1) @@ -319,6 +327,12 @@ class MeshCoreBot: new_bot_tx_rate_limit = self.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0) self.bot_tx_rate_limiter = BotTxRateLimiter(new_bot_tx_rate_limit) + self.per_user_rate_limit_enabled = self.config.getboolean( + 'Bot', 'per_user_rate_limit_enabled', fallback=True + ) + new_per_user_seconds = self.config.getfloat('Bot', 'per_user_rate_limit_seconds', fallback=5.0) + self.per_user_rate_limiter = PerUserRateLimiter(seconds=new_per_user_seconds, max_entries=1000) + new_nominatim_rate_limit = self.config.getfloat('Bot', 'nominatim_rate_limit_seconds', fallback=1.1) self.nominatim_rate_limiter = NominatimRateLimiter(new_nominatim_rate_limit) diff --git a/modules/message_handler.py b/modules/message_handler.py index 2f3083b..f0e57bc 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -2895,10 +2895,15 @@ class MessageHandler: # Send response (pass command_id so transmission record uses it directly) 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) + 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) + 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 keyword response for '{keyword}' to {message.sender_id if message.is_dm else message.channel}") diff --git a/modules/rate_limiter.py b/modules/rate_limiter.py index b25c028..29b9c60 100644 --- a/modules/rate_limiter.py +++ b/modules/rate_limiter.py @@ -6,7 +6,55 @@ Controls how often messages can be sent to prevent spam import time import asyncio -from typing import Optional +from typing import Optional, Dict, List + + +class PerUserRateLimiter: + """Per-user rate limiting: minimum seconds between bot replies to the same user. + + User identity is keyed by rate_limit_key (pubkey when available, else sender name). + The key map is bounded by max_entries; eviction of oldest entries may allow a + previously rate-limited user to send again slightly earlier. + """ + + def __init__(self, seconds: float, max_entries: int = 1000): + self.seconds = seconds + self.max_entries = max_entries + self._last_send: Dict[str, float] = {} + self._order: List[str] = [] # keys in insertion order for oldest-first eviction + + def _evict_if_needed(self, new_key: str) -> None: + """Evict oldest entry if at capacity and new_key is not already present.""" + if new_key in self._last_send: + return + while len(self._last_send) >= self.max_entries and self._order: + oldest = self._order.pop(0) + self._last_send.pop(oldest, None) + + def can_send(self, key: str) -> bool: + """Check if we can send a message to this user (key).""" + if not key: + return True + last = self._last_send.get(key, 0) + return time.time() - last >= self.seconds + + def time_until_next(self, key: str) -> float: + """Get time until next allowed send for this user.""" + if not key: + return 0.0 + last = self._last_send.get(key, 0) + elapsed = time.time() - last + return max(0.0, self.seconds - elapsed) + + def record_send(self, key: str) -> None: + """Record that we sent a message to this user.""" + if not key: + return + self._evict_if_needed(key) + self._last_send[key] = time.time() + if key in self._order: + self._order.remove(key) + self._order.append(key) class RateLimiter: