mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-01 16:48:26 +00:00
Correctness:
- {path_distance} was always blank in production. The resolution code that
builds repeater_info dropped latitude/longitude in every branch, so the
calculator could never find a coordinate. My tests passed hand-built
dicts straight to the calculator and never exercised the builder, which
is why they stayed green. Coordinates are now carried through all four
construction sites, and the new tests drive _lookup_repeater_names via
its lookup_func hook so the real builder runs.
- The #80 route guard was defeated two ways in the channel handler. When
the RF data was an uncorrelated fallback, control fell through to the
raw-hex and routing_info fallbacks below, which took the route from the
unrelated packet anyway; the guard had actually made that path
reachable. message.routing_info was also assigned unconditionally, and
the path command reads it. "Not attributable" is now a terminal branch
and the routing_info hand-off checks provenance.
- Same fix was incomplete for DMs: routing_info was captured and turned
into path_info before the provenance check ran, so the later check only
declined to overwrite an already-wrong value. Guarded at the source.
- Rendering could transmit for real. Capture only intercepts
send_response, but advert calls send_advert() directly and
send_response_chunked never checked capture_sink. Chunked sends are now
captured, and rendering is opt-in via BaseCommand.render_safe (default
False) instead of a denylist that cannot be complete. This also closes
the DM-only leak: schedule is not marked safe, so {cmd:schedule} can no
longer broadcast configuration to a channel.
- Multi-part rendered output was rejoined into one oversized send.
Scheduled messages are now split to the RF body budget and sent through
send_channel_messages_chunked, on character boundaries so multi-byte
text is not corrupted.
- _last_path_distance_km is instance state that was only set on success,
so an invalid path request could show the previous request's distance.
Reset at the start of every execute().
- Stale-contact retries were only counted on non-OK results, so timeouts
and exceptions left a contact eligible forever and could recreate the
storm. All failed attempts count now.
Web viewer:
- A failed config reload was reported as a successful save. The API and
UI now distinguish "saved and active" from "saved, restart needed".
- Preview count was unbounded; clamped to 1-20.
- update_ini_values is a read-modify-replace with no locking, so two
concurrent viewer saves could lose one. Serialised behind a lock.
179 lines
5.7 KiB
Python
179 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Roll command for the MeshCore Bot
|
|
Handles random number generation between 1 and X (default 100)
|
|
"""
|
|
|
|
import random
|
|
from typing import Optional
|
|
|
|
from ..models import MeshMessage
|
|
from .base_command import BaseCommand
|
|
|
|
|
|
class RollCommand(BaseCommand):
|
|
"""Handles random number rolling commands.
|
|
|
|
This command generates a random number between 1 and a specified maximum (default 100).
|
|
It supports syntax like 'roll' or 'roll 50'.
|
|
"""
|
|
|
|
# Plugin metadata
|
|
# Read-only informational output; safe for scheduled {cmd:...} rendering.
|
|
render_safe = True
|
|
name = "roll"
|
|
keywords = ['roll']
|
|
description = "Roll a random number between 1 and X (default 100). Use 'roll' for 1-100, 'roll 50' for 1-50, etc."
|
|
category = "games"
|
|
|
|
# Documentation
|
|
short_description = "Roll a random number between 1 and X"
|
|
usage = "roll [max]"
|
|
examples = ["roll", "roll 50"]
|
|
parameters = [
|
|
{"name": "max", "description": "Maximum value (default: 100, max: 10000)"}
|
|
]
|
|
|
|
def __init__(self, bot):
|
|
"""Initialize the roll command.
|
|
|
|
Args:
|
|
bot: The bot instance.
|
|
"""
|
|
super().__init__(bot)
|
|
self.roll_enabled = self.get_config_value('Roll_Command', 'enabled', fallback=True, value_type='bool')
|
|
|
|
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
|
|
"""Check if this command can be executed with the given message.
|
|
|
|
Args:
|
|
message: The message triggering the command.
|
|
|
|
Returns:
|
|
bool: True if command is enabled and checks pass, False otherwise.
|
|
"""
|
|
if not self.roll_enabled:
|
|
return False
|
|
return super().can_execute(message)
|
|
|
|
def get_help_text(self) -> str:
|
|
"""Get help text for the roll command.
|
|
|
|
Returns:
|
|
str: The help text for this command.
|
|
"""
|
|
return self.translate('commands.roll.help')
|
|
|
|
def matches_keyword(self, message: MeshMessage) -> bool:
|
|
"""Override to handle roll-specific matching.
|
|
|
|
Custom matching logic to support variable maximums (e.g., "roll 50").
|
|
|
|
Args:
|
|
message: The message to check for a match.
|
|
|
|
Returns:
|
|
bool: True if the message matches the roll command syntax, False otherwise.
|
|
"""
|
|
content_lower = self.cleanup_message_for_matching(message)
|
|
|
|
# Check for exact "roll" match
|
|
if content_lower == "roll":
|
|
return True
|
|
|
|
# Check for roll with parameters (roll 50, roll 1000, etc.)
|
|
# Ensure "roll" is the first word and followed by valid number
|
|
if content_lower.startswith("roll "):
|
|
words = content_lower.split()
|
|
if len(words) >= 2 and words[0] == "roll":
|
|
roll_part = content_lower[5:].strip() # Get everything after "roll "
|
|
# Check if the roll part is valid number notation (not just any word)
|
|
max_num = self.parse_roll_notation(roll_part)
|
|
return max_num is not None # Only match if it's valid number notation
|
|
|
|
return False
|
|
|
|
def parse_roll_notation(self, roll_input: str) -> Optional[int]:
|
|
"""Parse roll notation and return the maximum number.
|
|
|
|
Supports inputs like: 50, 100, 1000.
|
|
|
|
Args:
|
|
roll_input: The string part containing the number.
|
|
|
|
Returns:
|
|
Optional[int]: The maximum number if valid, None otherwise.
|
|
"""
|
|
roll_input = roll_input.strip()
|
|
|
|
# Handle direct number (e.g., "50", "100", "1000")
|
|
if roll_input.isdigit():
|
|
max_num = int(roll_input)
|
|
if 1 <= max_num <= 10000: # Reasonable limit
|
|
return max_num
|
|
else:
|
|
return None
|
|
|
|
return None
|
|
|
|
def roll_number(self, max_num: int) -> int:
|
|
"""Roll a random number between 1 and max_num (inclusive).
|
|
|
|
Args:
|
|
max_num: The maximum possible value.
|
|
|
|
Returns:
|
|
int: The generated random number.
|
|
"""
|
|
return random.randint(1, max_num)
|
|
|
|
def format_roll_result(self, max_num: int, result: int) -> str:
|
|
"""Format roll result into a readable string.
|
|
|
|
Args:
|
|
max_num: The maximum number for the roll.
|
|
result: The actual rolled number.
|
|
|
|
Returns:
|
|
str: The formatted result string.
|
|
"""
|
|
return self.translate('commands.roll.result', max=max_num, result=result)
|
|
|
|
async def execute(self, message: MeshMessage) -> bool:
|
|
"""Execute the roll command.
|
|
|
|
Parses the maximum number (if provided), generates a random number,
|
|
and sends the result to the user.
|
|
|
|
Args:
|
|
message: The message triggering the command.
|
|
|
|
Returns:
|
|
bool: True if executed successfully, False otherwise.
|
|
"""
|
|
content = message.content.strip()
|
|
|
|
# Handle command-style messages
|
|
if content.startswith('!'):
|
|
content = content[1:].strip()
|
|
|
|
# Default to 1-100 if no specification
|
|
if content.lower() == "roll":
|
|
max_num: Optional[int] = 100
|
|
else:
|
|
# Parse roll specification
|
|
roll_part = content[5:].strip() # Get everything after "roll "
|
|
max_num = self.parse_roll_notation(roll_part)
|
|
|
|
if max_num is None:
|
|
# Invalid roll specification
|
|
response = self.translate('commands.roll.invalid_number')
|
|
return await self.send_response(message, response)
|
|
|
|
# Roll the number
|
|
result = self.roll_number(max_num)
|
|
|
|
# Format and send response
|
|
response = self.format_roll_result(max_num, result)
|
|
return await self.send_response(message, response)
|