fix: BUG-025/026/027/028/029 implementations and ruff/mypy refinements

BUG-025: send_channel_message retry logic on no_event_received
BUG-026: split_text_into_chunks and chunked dispatch in message_handler
BUG-027: test_weekly_on_wrong_day_does_not_run patch uses fake_now
BUG-028: byte_data = b"" initialised before try in decode_meshcore_packet
BUG-029: app.py db_path via self._config_base; realtime.html socket race
  fixed; base.html forceNew removed; ping_timeout 5 to 20s

Additional: ruff and mypy refinements across all modules; discord bridge,
telegram bridge, rate limiter, and service plugin updates
This commit is contained in:
Stacy Olivas
2026-03-17 17:46:27 -07:00
parent ba83acb064
commit c9cfcdfe00
66 changed files with 1086 additions and 241 deletions
+92 -15
View File
@@ -330,6 +330,15 @@ class CommandManager:
return True, ""
def _is_no_event_received(self, result) -> bool:
"""Return True when result is an ERROR event with reason 'no_event_received'."""
if not result or not hasattr(result, 'type'):
return False
if result.type != EventType.ERROR:
return False
payload = result.payload if hasattr(result, 'payload') else {}
return isinstance(payload, dict) and payload.get('reason') == 'no_event_received'
def _handle_send_result(
self,
result,
@@ -552,6 +561,29 @@ class CommandManager:
mesh_info=None # Keywords don't use mesh info placeholders
)
def get_max_message_length(self, message: MeshMessage) -> int:
"""Return the effective max message length for *message* (DM=150, channel=150-prefix).
Mirrors ``BaseCommand.get_max_message_length`` but works on the manager level so it
can be called outside of a specific command instance.
"""
if message.is_dm:
return 150
username: str | None = None
try:
if hasattr(self.bot, 'meshcore') and self.bot.meshcore:
self_info = getattr(self.bot.meshcore, 'self_info', None)
if self_info:
if isinstance(self_info, dict):
username = self_info.get('name') or self_info.get('user_name')
else:
username = getattr(self_info, 'name', None) or getattr(self_info, 'user_name', None)
except Exception:
pass
if not username:
username = self.bot.config.get('Bot', 'bot_name', fallback='Bot')
return max(1, 150 - len(username) - 2)
def check_keywords(self, message: MeshMessage) -> list[tuple]:
"""Check message content for keywords and return matching responses.
@@ -564,7 +596,7 @@ class CommandManager:
Returns:
List[tuple]: List of (trigger, response) tuples for matched keywords.
"""
matches = []
matches: list[tuple[str, Optional[str]]] = []
content = message.content.strip()
# Check for command prefix if configured
@@ -1022,15 +1054,29 @@ class CommandManager:
if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"):
await self.bot.meshcore.commands.set_flood_scope(scope_to_use)
try:
# Use meshcore_py directly (no meshcore-cli for channel sends)
result = await self.bot.meshcore.commands.send_chan_msg(channel_num, content)
finally:
if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"):
await self.bot.meshcore.commands.set_flood_scope("*")
target = f"{channel} (channel {channel_num})"
# Retry on no_event_received: max 2 extra attempts, 2s apart
_max_retries = 2
for _attempt in range(_max_retries + 1):
try:
result = await self.bot.meshcore.commands.send_chan_msg(channel_num, content)
finally:
if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"):
await self.bot.meshcore.commands.set_flood_scope("*")
if self._is_no_event_received(result) and _attempt < _max_retries:
self.logger.warning(
f"Channel message to {target}: no_event_received "
f"(attempt {_attempt + 1}/{_max_retries + 1}), retrying in 2s"
)
await asyncio.sleep(2)
# Re-apply scope for next attempt
if not scope_is_global and hasattr(self.bot.meshcore.commands, "set_flood_scope"):
await self.bot.meshcore.commands.set_flood_scope(scope_to_use)
continue
break
# Handle result using unified handler
target = f"{channel} (channel {channel_num})"
success = self._handle_send_result(
result, "Channel message", target, rate_limit_key=rate_limit_key
)
@@ -1110,7 +1156,7 @@ class CommandManager:
return False
return True
def get_help_for_command(self, command_name: str, message: MeshMessage = None) -> str:
def get_help_for_command(self, command_name: str, message: Optional[MeshMessage] = None) -> str:
"""Get help text for a specific command (LoRa-friendly compact format).
Args:
@@ -1191,7 +1237,7 @@ class CommandManager:
_HELP_PREFIX = "Bot Help: "
_HELP_SUFFIX = " | More: 'help <command>'"
def get_general_help(self, message: MeshMessage = None) -> str:
def get_general_help(self, message: Optional[MeshMessage] = None) -> str:
"""Get general help text from config (LoRa-friendly compact format).
When message is provided, only lists commands valid for the message's channel.
@@ -1313,13 +1359,13 @@ class CommandManager:
rate_limit_key = self.get_rate_limit_key(message)
if message.is_dm:
return await self.send_dm(
message.sender_id, content,
message.sender_id or "", 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,
message.channel or "", content,
skip_user_rate_limit=skip_user_rate_limit,
rate_limit_key=rate_limit_key,
)
@@ -1327,6 +1373,37 @@ class CommandManager:
self.logger.error(f"Failed to send response: {e}")
return False
@staticmethod
def split_text_into_chunks(text: str, max_len: int) -> list[str]:
"""Split *text* into a list of strings each at most *max_len* characters.
Splitting prefers the last space within the limit so words are not broken;
if no space is found the chunk is hard-split at *max_len*.
Args:
text: The text to split.
max_len: Maximum length of each chunk (must be >= 1).
Returns:
List of non-empty chunk strings. Returns ``[""]`` when *text* is empty.
"""
if max_len < 1:
max_len = 1
if len(text) <= max_len:
return [text]
chunks: list[str] = []
while text:
if len(text) <= max_len:
chunks.append(text)
break
# Try to split on the last space within the window
split_at = text.rfind(' ', 0, max_len + 1)
if split_at <= 0:
split_at = max_len
chunks.append(text[:split_at].rstrip())
text = text[split_at:].lstrip()
return chunks
async def send_response_chunked(
self, message: MeshMessage, chunks: list[str], *, skip_user_rate_limit_first: bool = True
) -> bool:
@@ -1357,7 +1434,7 @@ class CommandManager:
await asyncio.sleep(sleep_time)
skip = skip_user_rate_limit_first if i == 0 else True
success = await self.send_dm(
message.sender_id,
message.sender_id or "",
chunk,
skip_user_rate_limit=skip,
rate_limit_key=rate_limit_key,
@@ -1370,7 +1447,7 @@ class CommandManager:
return False
return True
return await self.send_channel_messages_chunked(
message.channel,
message.channel or "",
chunks,
skip_user_rate_limit=skip_user_rate_limit_first,
rate_limit_key=rate_limit_key,
@@ -1651,6 +1728,6 @@ class CommandManager:
"""Reload a specific plugin"""
return self.plugin_loader.reload_plugin(plugin_name)
def get_plugin_metadata(self, plugin_name: str = None) -> dict[str, Any]:
def get_plugin_metadata(self, plugin_name: Optional[str] = None) -> dict[str, Any]:
"""Get plugin metadata"""
return self.plugin_loader.get_plugin_metadata(plugin_name)