Files
meshcore-bot/modules/commands/moon_command.py
T
agessaman fc63039a48 fix: address Codex review of tonight's work
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.
2026-08-22 00:16:03 -07:00

187 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""
Moon Command - Provides moon phase and position information
"""
from ..models import MeshMessage
from ..solar_conditions import get_moon
from .base_command import BaseCommand
class MoonCommand(BaseCommand):
"""Command to get moon information"""
# Plugin metadata
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "moon"
keywords = ['moon']
description = "Get moon phase, rise/set times and position"
category = "solar"
# Documentation
short_description = "Get moon phase and rise/set times"
usage = "moon"
examples = ["moon"]
def __init__(self, bot):
"""Initialize the moon command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
self.moon_enabled = self.get_config_value('Moon_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.moon_enabled:
return False
return super().can_execute(message)
async def execute(self, message: MeshMessage) -> bool:
"""Execute the moon command.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
try:
# Get moon information using default location
moon_info = get_moon()
# Format the response to be more compact and readable
response = self._format_moon_response(moon_info)
# Use the unified send_response method
await self.send_response(message, response)
return True
except Exception as e:
error_msg = self.translate('commands.moon.error', error=str(e))
await self.send_response(message, error_msg)
return False
def _translate_phase_name(self, phase_name: str) -> str:
"""Translate English phase name to localized version.
Args:
phase_name: The English phase name (e.g., 'New Moon').
Returns:
str: The translated phase name, or original if not found.
"""
# Map English phase names (with or without emoji) to translation keys
phase_mapping = {
'New Moon': 'new_moon',
'Waxing Crescent': 'waxing_crescent',
'First Quarter': 'first_quarter',
'Waxing Gibbous': 'waxing_gibbous',
'Full Moon': 'full_moon',
'Waning Gibbous': 'waning_gibbous',
'Last Quarter': 'last_quarter',
'Waning Crescent': 'waning_crescent'
}
# Remove emoji if present (they're in the translation)
phase_clean = phase_name
for emoji in ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']:
phase_clean = phase_clean.replace(emoji, '').strip()
# Find matching translation key
for english_name, translation_key in phase_mapping.items():
if english_name in phase_clean:
translated = self.translate(f'commands.moon.phases.{translation_key}')
# If translation found (not just the key), return it
if translated != f'commands.moon.phases.{translation_key}':
return translated
# Fallback: return original if no translation found
return phase_name
def _format_moon_response(self, moon_info: str) -> str:
"""Format moon information to be more compact and readable.
Args:
moon_info: The raw moon info string.
Returns:
str: The formatted response string.
"""
try:
# Parse the moon info string to extract key information
lines = moon_info.split('\n')
moon_data = {}
for line in lines:
if ':' in line:
key, value = line.split(':', 1)
moon_data[key.strip()] = value.strip()
# Create a more compact format while keeping essential details
if 'MoonRise' in moon_data and 'Set' in moon_data and 'Phase' in moon_data:
# Keep day info but make it more compact
rise_info = moon_data['MoonRise'] # e.g., "Thu 04 06:47PM"
set_info = moon_data['Set'] # e.g., "Fri 05 03:43AM"
# Extract phase and illumination
phase_info = moon_data.get('Phase', self.translate('commands.moon.unknown_phase'))
if '@:' in phase_info:
phase, illum = phase_info.split('@:')
phase = phase.strip()
illum = illum.strip()
# Translate the phase name
phase = self._translate_phase_name(phase)
else:
phase = phase_info
phase = self._translate_phase_name(phase)
illum = self.translate('commands.moon.unknown_illum')
# Add next full and new moon dates (compact format)
if 'FullMoon' in moon_data and 'NewMoon' in moon_data:
full_moon = moon_data['FullMoon'] # e.g., "Sun Sep 07 11:08AM"
new_moon = moon_data['NewMoon'] # e.g., "Sun Sep 21 12:54PM"
# Extract just the essential date/time parts
full_parts = full_moon.split()
new_parts = new_moon.split()
if len(full_parts) >= 3 and len(new_parts) >= 3:
# Format: "Sep 07 11:08AM" and "Sep 21 12:54PM"
full_compact = f"{full_parts[1]} {full_parts[2]} {full_parts[3]}"
new_compact = f"{new_parts[1]} {new_parts[2]} {new_parts[3]}"
# Create compact response with full/new moon dates
return self.translate('commands.moon.format_with_dates',
phase=phase, illum=illum,
rise=rise_info, set=set_info,
full=full_compact, new=new_compact)
# Create compact response without full/new moon dates
return self.translate('commands.moon.format',
phase=phase, illum=illum,
rise=rise_info, set=set_info)
else:
# Fallback to original format if parsing fails
return self.translate('commands.moon.fallback', info=moon_info)
except Exception:
# Fallback to original format if formatting fails
return self.translate('commands.moon.fallback', info=moon_info)
def get_help_text(self) -> str:
"""Get help text for this command.
Returns:
str: The help text for this command.
"""
return self.description