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