feat: Implement per-user rate limiting for bot responses

- Added per-user rate limiting functionality to control the minimum time between bot replies to the same user, identified by public key or sender name.
- Updated configuration files to include `per_user_rate_limit_seconds` and `per_user_rate_limit_enabled` options for enabling and configuring this feature.
- Enhanced the command manager and message handler to support per-user rate limiting, ensuring efficient message handling and reduced spam.
- Updated documentation to reflect the new rate limiting options and their usage.
This commit is contained in:
Adam Gessaman
2026-02-11 09:13:24 -08:00
parent ac2825a25d
commit 40e30def2c
7 changed files with 173 additions and 21 deletions
+8 -3
View File
@@ -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
+7
View File
@@ -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
+5
View File
@@ -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
+82 -14
View File
@@ -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
+15 -1
View File
@@ -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)
+7 -2
View File
@@ -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}")
+49 -1
View File
@@ -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: