mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-03-30 20:15:40 +00:00
- Introduced a new utility function `get_config_timezone` to centralize timezone retrieval and validation from the bot's configuration. - Updated various commands and the scheduler to utilize the new function, ensuring consistent timezone handling and fallback mechanisms. - Removed direct dependencies on `pytz` in favor of a more flexible approach that supports both `pytz` and `zoneinfo` for timezone management. - Enhanced logging for invalid timezone configurations to improve troubleshooting.
1107 lines
53 KiB
Python
1107 lines
53 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Solar Forecast command for the MeshCore Bot
|
|
Provides solar panel production forecasts using Forecast.Solar API
|
|
"""
|
|
|
|
import re
|
|
import requests
|
|
import time
|
|
import hashlib
|
|
import pytz
|
|
from geopy.geocoders import Nominatim
|
|
from ..utils import rate_limited_nominatim_reverse, get_nominatim_geocoder, geocode_zipcode, geocode_city, get_config_timezone
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Optional, Tuple, Dict
|
|
from .base_command import BaseCommand
|
|
from ..models import MeshMessage
|
|
from ..utils import abbreviate_location
|
|
|
|
|
|
class SolarforecastCommand(BaseCommand):
|
|
"""Handles solar forecast commands with location support"""
|
|
|
|
# Plugin metadata
|
|
name = "solarforecast"
|
|
keywords = ['solarforecast', 'sf']
|
|
description = "Get solar panel production forecast (usage: sf <location|repeater_name|coordinates|zipcode> [panel_size] [azimuth, 0=south] [angle])"
|
|
category = "solar"
|
|
cooldown_seconds = 10 # 10 second cooldown per user
|
|
requires_internet = True # Requires internet access for Forecast.Solar API and geocoding
|
|
|
|
# Documentation
|
|
short_description = "Get solar panel production forecast for a location or repeater"
|
|
usage = "sf <location> [watts] [azimuth] [angle]"
|
|
examples = ["sf seattle", "sf 47.6,-122.3 200"]
|
|
parameters = [
|
|
{"name": "location", "description": "City, coordinates, or repeater name"},
|
|
{"name": "watts", "description": "Panel size in watts (default: 100)"},
|
|
{"name": "azimuth", "description": "Panel direction, 0=south (default: 0)"},
|
|
{"name": "angle", "description": "Panel tilt in degrees (default: 30)"}
|
|
]
|
|
|
|
# Error constants - will use translations instead
|
|
ERROR_FETCHING_DATA = "Error fetching forecast" # Deprecated - use translate
|
|
NO_DATA_AVAILABLE = "No forecast data" # Deprecated - use translate
|
|
|
|
# Forecast.Solar minimum panel size (10W)
|
|
MIN_KWP = 0.01
|
|
|
|
# Cache duration in seconds (30 minutes)
|
|
CACHE_DURATION = 30 * 60
|
|
|
|
def __init__(self, bot):
|
|
super().__init__(bot)
|
|
self.solarforecast_enabled = self.get_config_value('Solarforecast_Command', 'enabled', fallback=True, value_type='bool')
|
|
self.url_timeout = 15 # seconds
|
|
|
|
# Forecast cache: {cache_key: {'data': dict, 'timestamp': float}}
|
|
self.forecast_cache = {}
|
|
|
|
# Get default state from config for city disambiguation
|
|
self.default_state = self.bot.config.get('Weather', 'default_state', fallback='')
|
|
|
|
# Initialize geocoder (will use rate-limited helpers for actual calls)
|
|
self.geolocator = get_nominatim_geocoder()
|
|
|
|
# Get database manager for geocoding cache
|
|
self.db_manager = bot.db_manager
|
|
|
|
def can_execute(self, message: MeshMessage) -> 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.solarforecast_enabled:
|
|
return False
|
|
return super().can_execute(message)
|
|
|
|
def get_help_text(self) -> str:
|
|
return self.translate('commands.solarforecast.usage')
|
|
|
|
def _translate_day_abbreviation(self, day_abbr: str) -> str:
|
|
"""Translate English day abbreviation to localized version"""
|
|
# Map English abbreviations to translation keys in common.date_time
|
|
translation_key = f'common.date_time.day_abbreviations.{day_abbr}'
|
|
translated = self.translate(translation_key)
|
|
# If translation found (not just the key), return it
|
|
if translated != translation_key:
|
|
return translated
|
|
# Fallback: return original if no translation found
|
|
return day_abbr
|
|
|
|
async def execute(self, message: MeshMessage) -> bool:
|
|
"""Execute the solar forecast command"""
|
|
content = message.content.strip()
|
|
|
|
# Parse command: sf <location> [panel_size] [azimuth] [angle]
|
|
parts = content.split()
|
|
if len(parts) < 2:
|
|
await self.send_response(message, self.translate('commands.solarforecast.usage_short'))
|
|
return True
|
|
|
|
try:
|
|
# Record execution for this user (handles cooldown)
|
|
self.record_execution(message.sender_id)
|
|
|
|
# Parse arguments - location might be multiple words (e.g., "Hillcrest Repeater v2")
|
|
# Try to find where location ends and numeric parameters begin
|
|
# But be careful: 5-digit numbers might be zip codes, not panel sizes
|
|
location_parts = []
|
|
param_start_idx = None
|
|
|
|
# Special case: if we only have 2 parts and the second is a non-5-digit number,
|
|
# it's likely "sf 10" which is invalid (no location provided)
|
|
if len(parts) == 2:
|
|
try:
|
|
num_value = float(parts[1])
|
|
if not (len(parts[1]) == 5 and parts[1].isdigit()):
|
|
# Single non-5-digit number - invalid, no location provided
|
|
await self.send_response(message, self.translate('commands.solarforecast.usage_short'))
|
|
return True
|
|
except ValueError:
|
|
pass # Not a number, continue with normal parsing
|
|
|
|
for i in range(1, len(parts)):
|
|
# Check if this part is a number (could be panel size, azimuth, or angle)
|
|
# Handle cases like "10w", "10W", or just "10"
|
|
try:
|
|
# Try to parse as number (strip 'w' or 'W' suffix if present)
|
|
num_str = parts[i].strip().rstrip('wW')
|
|
num_value = float(num_str)
|
|
# Check if it's a 5-digit number (likely a zip code)
|
|
# But only if it doesn't have a 'w' suffix (zip codes don't have 'w')
|
|
if len(parts[i]) == 5 and parts[i].isdigit():
|
|
# Could be a zip code - include it in location
|
|
location_parts.append(parts[i])
|
|
else:
|
|
# This is a number and not a zip code - location ends before this
|
|
param_start_idx = i
|
|
break
|
|
except ValueError:
|
|
# Not a number, part of location name
|
|
location_parts.append(parts[i])
|
|
|
|
location_str = ' '.join(location_parts) if location_parts else (parts[1] if len(parts) > 1 else "")
|
|
|
|
# Clean location string - remove control characters and non-printable characters
|
|
location_str = self._clean_location_string(location_str)
|
|
|
|
# Validate that we have a location
|
|
if not location_str:
|
|
await self.send_response(message, self.translate('commands.solarforecast.usage_short'))
|
|
return True
|
|
|
|
# Parse optional parameters
|
|
panel_watts = 10.0 # Default 10W
|
|
azimuth = 0 # Default south
|
|
angle = 45 # Default 45° tilt
|
|
|
|
# Try to parse panel size (in watts)
|
|
# Handle cases like "10w", "10W", or just "10"
|
|
if param_start_idx is not None and param_start_idx < len(parts):
|
|
try:
|
|
panel_str = parts[param_start_idx].strip().rstrip('wW')
|
|
panel_watts = float(panel_str)
|
|
if panel_watts <= 0 or panel_watts > 1000:
|
|
await self.send_response(message, self.translate('commands.solarforecast.panel_size_range'))
|
|
return True
|
|
except ValueError:
|
|
pass
|
|
|
|
# Try to parse azimuth
|
|
if param_start_idx is not None and param_start_idx + 1 < len(parts):
|
|
try:
|
|
azimuth = float(parts[param_start_idx + 1])
|
|
if not (-180 <= azimuth <= 180):
|
|
await self.send_response(message, self.translate('commands.solarforecast.azimuth_range'))
|
|
return True
|
|
except ValueError:
|
|
pass
|
|
|
|
# Try to parse angle (tilt)
|
|
if param_start_idx is not None and param_start_idx + 2 < len(parts):
|
|
try:
|
|
angle = float(parts[param_start_idx + 2])
|
|
if not (0 <= angle <= 90):
|
|
await self.send_response(message, self.translate('commands.solarforecast.angle_range'))
|
|
return True
|
|
except ValueError:
|
|
pass
|
|
|
|
# Parse location (check for repeater name first, then coordinates, zip, or city)
|
|
lat, lon, location_type = await self._parse_location(location_str)
|
|
if lat is None or lon is None:
|
|
await self.send_response(message, self.translate('commands.solarforecast.no_location', location=location_str))
|
|
return True
|
|
|
|
# Get location name for confirmation
|
|
location_name = await self._get_location_name(lat, lon, location_str, location_type)
|
|
|
|
# Get forecast
|
|
forecast_text = await self._get_forecast(lat, lon, panel_watts, azimuth, angle, location_name)
|
|
|
|
# Send response - handle multi-line messages
|
|
await self._send_forecast_response(message, forecast_text)
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error in solar forecast command: {e}")
|
|
await self.send_response(message, self.translate('commands.solarforecast.error', error=str(e)))
|
|
return True
|
|
|
|
def _clean_location_string(self, location: str) -> str:
|
|
"""Clean location string by removing control characters and non-printable characters"""
|
|
if not location:
|
|
return location
|
|
|
|
# Remove control characters (0x00-0x1F) except space, tab, newline, carriage return
|
|
# Also remove DEL (0x7F) and other non-printable characters
|
|
cleaned = ''.join(
|
|
char for char in location
|
|
if char.isprintable() or char in (' ', '\t', '\n', '\r')
|
|
)
|
|
|
|
# Strip whitespace from both ends
|
|
cleaned = cleaned.strip()
|
|
|
|
# Remove any remaining non-ASCII control characters
|
|
cleaned = ''.join(char for char in cleaned if ord(char) >= 32 or char in ('\t', '\n', '\r'))
|
|
|
|
# Remove trailing single '<' character (often appears as garbage from double-submit)
|
|
# Also remove any trailing control-like characters
|
|
while cleaned and (cleaned[-1] == '<' or (len(cleaned) > 1 and ord(cleaned[-1]) < 32)):
|
|
cleaned = cleaned[:-1].rstrip()
|
|
|
|
return cleaned
|
|
|
|
async def _parse_location(self, location: str) -> Tuple[Optional[float], Optional[float], str]:
|
|
"""Parse location string to lat/lon"""
|
|
# First, check if it's a repeater name
|
|
self.logger.debug(f"Checking if '{location}' is a repeater name...")
|
|
lat, lon = await self._repeater_name_to_lat_lon(location)
|
|
if lat is not None and lon is not None:
|
|
self.logger.debug(f"Found repeater '{location}' at {lat}, {lon}")
|
|
return lat, lon, "repeater"
|
|
else:
|
|
self.logger.debug(f"No repeater found for '{location}', trying other location types...")
|
|
|
|
# Check if it's coordinates (lat,lon)
|
|
if re.match(r'^\s*-?\d+\.?\d*\s*,\s*-?\d+\.?\d*\s*$', location):
|
|
try:
|
|
lat_str, lon_str = location.split(',')
|
|
lat = float(lat_str.strip())
|
|
lon = float(lon_str.strip())
|
|
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
|
return lat, lon, "coordinates"
|
|
except ValueError:
|
|
pass
|
|
|
|
# Check if it's a zipcode (5 digits)
|
|
if re.match(r'^\s*\d{5}\s*$', location):
|
|
lat, lon = await self._zipcode_to_lat_lon(location)
|
|
if lat and lon:
|
|
return lat, lon, "zipcode"
|
|
|
|
# Otherwise, treat as city name
|
|
lat, lon = await self._city_to_lat_lon(location)
|
|
return lat, lon, "city"
|
|
|
|
async def _repeater_name_to_lat_lon(self, repeater_name: str) -> Tuple[Optional[float], Optional[float]]:
|
|
"""Look up repeater by name and return its lat/lon"""
|
|
try:
|
|
if not hasattr(self.bot, 'db_manager'):
|
|
return None, None
|
|
|
|
# Query complete_contact_tracking table for matching name
|
|
# Use case-insensitive matching and allow partial matches
|
|
# Filter for repeaters and roomservers only
|
|
query = '''
|
|
SELECT latitude, longitude, name
|
|
FROM complete_contact_tracking
|
|
WHERE role IN ('repeater', 'roomserver')
|
|
AND latitude IS NOT NULL
|
|
AND longitude IS NOT NULL
|
|
AND latitude != 0
|
|
AND longitude != 0
|
|
AND LOWER(name) LIKE LOWER(?)
|
|
ORDER BY
|
|
CASE
|
|
WHEN LOWER(name) = LOWER(?) THEN 1
|
|
WHEN LOWER(name) LIKE LOWER(?) THEN 2
|
|
ELSE 3
|
|
END,
|
|
COALESCE(last_advert_timestamp, last_heard) DESC
|
|
LIMIT 1
|
|
'''
|
|
|
|
# Try exact match first, then partial match
|
|
exact_pattern = repeater_name.strip()
|
|
partial_pattern = f"%{exact_pattern}%"
|
|
|
|
results = self.bot.db_manager.execute_query(
|
|
query,
|
|
(partial_pattern, exact_pattern, f"{exact_pattern}%")
|
|
)
|
|
|
|
self.logger.debug(f"Repeater lookup query returned {len(results) if results else 0} results for '{repeater_name}'")
|
|
|
|
if results and len(results) > 0:
|
|
row = results[0]
|
|
lat = row.get('latitude')
|
|
lon = row.get('longitude')
|
|
name = row.get('name', '')
|
|
|
|
self.logger.debug(f"Repeater match: name='{name}', lat={lat}, lon={lon}")
|
|
|
|
if lat is not None and lon is not None:
|
|
self.logger.debug(f"Found repeater '{name}' at {lat}, {lon}")
|
|
return float(lat), float(lon)
|
|
else:
|
|
self.logger.debug(f"Repeater '{name}' found but missing coordinates")
|
|
else:
|
|
# Let's also check what repeaters exist in the database for debugging
|
|
all_repeaters_query = '''
|
|
SELECT name, latitude, longitude
|
|
FROM complete_contact_tracking
|
|
WHERE role IN ('repeater', 'roomserver')
|
|
AND latitude IS NOT NULL
|
|
AND longitude IS NOT NULL
|
|
AND latitude != 0
|
|
AND longitude != 0
|
|
ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC
|
|
LIMIT 10
|
|
'''
|
|
all_repeaters = self.bot.db_manager.execute_query(all_repeaters_query)
|
|
if all_repeaters:
|
|
self.logger.debug(f"Sample repeaters in DB: {[r.get('name') for r in all_repeaters[:5]]}")
|
|
|
|
return None, None
|
|
except Exception as e:
|
|
self.logger.debug(f"Error looking up repeater '{repeater_name}': {e}")
|
|
return None, None
|
|
|
|
async def _zipcode_to_lat_lon(self, zipcode: str) -> Tuple[Optional[float], Optional[float]]:
|
|
"""Convert zipcode to lat/lon using shared geocoding function"""
|
|
try:
|
|
lat, lon = await geocode_zipcode(self.bot, zipcode, timeout=self.url_timeout)
|
|
return lat, lon
|
|
except Exception as e:
|
|
self.logger.error(f"Error geocoding zipcode {zipcode}: {e}")
|
|
return None, None
|
|
|
|
async def _city_to_lat_lon(self, city: str) -> Tuple[Optional[float], Optional[float]]:
|
|
"""Convert city name to lat/lon using shared geocoding function"""
|
|
try:
|
|
# Get defaults from config
|
|
default_country = self.bot.config.get('Weather', 'default_country', fallback='US')
|
|
lat, lon, _ = await geocode_city(
|
|
self.bot, city,
|
|
default_state=self.default_state,
|
|
default_country=default_country,
|
|
include_address_info=False, # Don't need address info, just coordinates
|
|
timeout=self.url_timeout
|
|
)
|
|
return lat, lon
|
|
except Exception as e:
|
|
self.logger.error(f"Error geocoding city {city}: {e}")
|
|
return None, None
|
|
|
|
async def _get_location_name(self, lat: float, lon: float, original_location: str,
|
|
location_type: str) -> str:
|
|
"""Get location name for confirmation (city, state)"""
|
|
# If it's a repeater, use the repeater name
|
|
if location_type == "repeater":
|
|
# Try to get the full repeater name from database
|
|
try:
|
|
if hasattr(self.bot, 'db_manager'):
|
|
query = '''
|
|
SELECT name
|
|
FROM complete_contact_tracking
|
|
WHERE role IN ('repeater', 'roomserver')
|
|
AND ABS(latitude - ?) < 0.001
|
|
AND ABS(longitude - ?) < 0.001
|
|
ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC
|
|
LIMIT 1
|
|
'''
|
|
results = self.bot.db_manager.execute_query(query, (lat, lon))
|
|
if results and len(results) > 0:
|
|
return results[0].get('name', original_location)
|
|
except Exception as e:
|
|
self.logger.debug(f"Error getting repeater name: {e}")
|
|
# Fallback to original location string
|
|
return original_location
|
|
|
|
try:
|
|
import asyncio
|
|
loop = asyncio.get_event_loop()
|
|
|
|
# For coordinates, always do reverse geocoding
|
|
if location_type == "coordinates":
|
|
location = await rate_limited_nominatim_reverse(
|
|
self.bot, f"{lat}, {lon}", timeout=self.url_timeout
|
|
)
|
|
if location and location.raw:
|
|
address = location.raw.get('address', {})
|
|
city = (address.get('city') or address.get('town') or
|
|
address.get('village') or address.get('municipality') or
|
|
address.get('suburb') or '')
|
|
state = address.get('state', '')
|
|
if city and state:
|
|
return f"{city}, {state}"
|
|
elif city:
|
|
return city
|
|
return original_location
|
|
|
|
# For city/zipcode, use original if it worked, or reverse geocode
|
|
if location_type in ["city", "zipcode"]:
|
|
# Try reverse geocoding to get confirmed city name
|
|
location = await rate_limited_nominatim_reverse(
|
|
self.bot, f"{lat}, {lon}", timeout=self.url_timeout
|
|
)
|
|
if location and location.raw:
|
|
address = location.raw.get('address', {})
|
|
city = (address.get('city') or address.get('town') or
|
|
address.get('village') or address.get('municipality') or
|
|
address.get('suburb') or '')
|
|
state = address.get('state', '')
|
|
if city and state:
|
|
return f"{city}, {state}"
|
|
elif city:
|
|
return city
|
|
|
|
# Fallback to original location string
|
|
return original_location
|
|
|
|
return original_location
|
|
except Exception as e:
|
|
self.logger.debug(f"Error getting location name: {e}")
|
|
return original_location
|
|
|
|
async def _get_forecast(self, lat: float, lon: float, panel_watts: float,
|
|
azimuth: float, angle: float, location_name: str = "") -> str:
|
|
"""Get solar forecast from Forecast.Solar API"""
|
|
try:
|
|
# Convert panel watts to kWp
|
|
kwp = panel_watts / 1000.0
|
|
|
|
# Get API key (optional - free tier works without it)
|
|
api_key = self.bot.config.get('External_Data', 'forecast_solar_api_key', fallback='')
|
|
if not api_key:
|
|
api_key = None
|
|
|
|
# Query Forecast.Solar with scaling for small panels
|
|
result = await self._query_forecast_solar_scaled(lat, lon, angle, azimuth, kwp, api_key)
|
|
|
|
if not result:
|
|
return self.translate('commands.solarforecast.error_fetching')
|
|
|
|
# Check for rate limiting
|
|
if result.get('rate_limited'):
|
|
return self.translate('commands.solarforecast.rate_limit')
|
|
|
|
# Format output to fit 130 characters
|
|
return self._format_forecast(result, panel_watts, location_name, lat, lon)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error getting forecast: {e}")
|
|
return self.translate('commands.solarforecast.error', error=str(e))
|
|
|
|
def _get_cache_key(self, lat: float, lon: float, declination: float,
|
|
azimuth: float, kwp: float, api_key: Optional[str]) -> str:
|
|
"""Generate a cache key from request parameters"""
|
|
# Round parameters to avoid cache misses due to floating point precision
|
|
key_data = f"{lat:.4f},{lon:.4f},{declination:.1f},{azimuth:.1f},{kwp:.4f},{api_key or 'free'}"
|
|
return hashlib.md5(key_data.encode()).hexdigest()
|
|
|
|
def _cleanup_expired_cache(self):
|
|
"""Remove all expired entries from cache"""
|
|
current_time = time.time()
|
|
expired_keys = []
|
|
for key, cached in self.forecast_cache.items():
|
|
age = current_time - cached['timestamp']
|
|
if age >= self.CACHE_DURATION:
|
|
expired_keys.append(key)
|
|
|
|
for key in expired_keys:
|
|
del self.forecast_cache[key]
|
|
|
|
if expired_keys:
|
|
self.logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries")
|
|
|
|
def _get_cached_forecast(self, cache_key: str) -> Optional[Dict]:
|
|
"""Get cached forecast if available and not expired"""
|
|
# Clean up expired entries periodically (every 10th access to avoid overhead)
|
|
if len(self.forecast_cache) > 0 and len(self.forecast_cache) % 10 == 0:
|
|
self._cleanup_expired_cache()
|
|
|
|
if cache_key in self.forecast_cache:
|
|
cached = self.forecast_cache[cache_key]
|
|
age = time.time() - cached['timestamp']
|
|
if age < self.CACHE_DURATION:
|
|
self.logger.debug(f"Using cached forecast (age: {age:.0f}s)")
|
|
return cached['data']
|
|
else:
|
|
# Cache expired, remove it
|
|
del self.forecast_cache[cache_key]
|
|
self.logger.debug(f"Cache expired (age: {age:.0f}s)")
|
|
return None
|
|
|
|
def _cache_forecast(self, cache_key: str, data: Dict):
|
|
"""Cache forecast data"""
|
|
self.forecast_cache[cache_key] = {
|
|
'data': data,
|
|
'timestamp': time.time()
|
|
}
|
|
self.logger.debug(f"Cached forecast data")
|
|
|
|
async def _query_forecast_solar_scaled(self, lat: float, lon: float, declination: float,
|
|
azimuth: float, kwp: float,
|
|
api_key: Optional[str]) -> Optional[Dict]:
|
|
"""Query Forecast.Solar API with automatic scaling for small panels"""
|
|
# Generate cache key for the actual query parameters (before scaling)
|
|
# We cache the base query (with MIN_KWP if scaling needed)
|
|
query_kwp = self.MIN_KWP if kwp < self.MIN_KWP else kwp
|
|
cache_key = self._get_cache_key(lat, lon, declination, azimuth, query_kwp, api_key)
|
|
|
|
# Check cache first
|
|
cached_result = self._get_cached_forecast(cache_key)
|
|
if cached_result:
|
|
# If we need scaling, apply it to cached data
|
|
if kwp < self.MIN_KWP:
|
|
scale_factor = kwp / self.MIN_KWP
|
|
return {
|
|
'watts': {k: v * scale_factor for k, v in cached_result['watts'].items()},
|
|
'watt_hours': {k: v * scale_factor for k, v in cached_result['watt_hours'].items()},
|
|
'watt_hours_day': {k: v * scale_factor for k, v in cached_result['watt_hours_day'].items()},
|
|
'num_days': cached_result['num_days'],
|
|
'scaled': True,
|
|
'scale_factor': scale_factor
|
|
}
|
|
else:
|
|
return cached_result
|
|
|
|
target_watts = kwp * 1000
|
|
min_watts = self.MIN_KWP * 1000
|
|
|
|
# Check if scaling is needed
|
|
if kwp < self.MIN_KWP:
|
|
scale_factor = kwp / self.MIN_KWP
|
|
# Query with minimum size
|
|
result = await self._query_forecast_solar(lat, lon, declination, azimuth, self.MIN_KWP, api_key)
|
|
if result:
|
|
# Cache the base result (before scaling)
|
|
self._cache_forecast(cache_key, result)
|
|
|
|
# Scale all values for return
|
|
return {
|
|
'watts': {k: v * scale_factor for k, v in result['watts'].items()},
|
|
'watt_hours': {k: v * scale_factor for k, v in result['watt_hours'].items()},
|
|
'watt_hours_day': {k: v * scale_factor for k, v in result['watt_hours_day'].items()},
|
|
'num_days': result['num_days'],
|
|
'scaled': True,
|
|
'scale_factor': scale_factor
|
|
}
|
|
return None
|
|
else:
|
|
# No scaling needed
|
|
result = await self._query_forecast_solar(lat, lon, declination, azimuth, kwp, api_key)
|
|
if result:
|
|
# Cache the result
|
|
self._cache_forecast(cache_key, result)
|
|
return result
|
|
|
|
async def _query_forecast_solar(self, lat: float, lon: float, declination: float,
|
|
azimuth: float, kwp: float,
|
|
api_key: Optional[str]) -> Optional[Dict]:
|
|
"""Query Forecast.Solar API"""
|
|
import asyncio
|
|
# Build URL
|
|
if api_key:
|
|
base_url = f"https://api.forecast.solar/{api_key}/estimate"
|
|
else:
|
|
base_url = "https://api.forecast.solar/estimate"
|
|
|
|
url = f"{base_url}/{lat}/{lon}/{declination}/{azimuth}/{kwp}"
|
|
|
|
try:
|
|
# Run HTTP request in executor to avoid blocking
|
|
loop = asyncio.get_event_loop()
|
|
response = await loop.run_in_executor(
|
|
None,
|
|
lambda: requests.get(url, timeout=self.url_timeout)
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
|
|
# Check for errors
|
|
if 'message' in data:
|
|
msg = data['message']
|
|
if msg.get('type') != 'success':
|
|
self.logger.error(f"Forecast.Solar API error: {msg.get('text', 'Unknown')}")
|
|
return None
|
|
|
|
result = data.get('result', {})
|
|
return {
|
|
'watts': result.get('watts', {}),
|
|
'watt_hours': result.get('watt_hours', {}),
|
|
'watt_hours_day': result.get('watt_hours_day', {}),
|
|
'num_days': len(result.get('watt_hours_day', {})),
|
|
'scaled': False
|
|
}
|
|
elif response.status_code == 429:
|
|
# Rate limit exceeded
|
|
self.logger.warning(f"Forecast.Solar rate limit exceeded (429)")
|
|
return {'rate_limited': True}
|
|
else:
|
|
self.logger.error(f"Forecast.Solar HTTP {response.status_code}")
|
|
return None
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error querying Forecast.Solar: {e}")
|
|
return None
|
|
|
|
def _format_forecast(self, result: Dict, panel_watts: float, location_name: str = "",
|
|
lat: float = None, lon: float = None) -> str:
|
|
"""Format forecast data to fit 130 characters with user-friendly labels"""
|
|
watt_hours_day = result.get('watt_hours_day', {})
|
|
num_days = result.get('num_days', 0)
|
|
|
|
if not watt_hours_day:
|
|
return self.translate('commands.solarforecast.no_data')
|
|
|
|
local_tz, _ = get_config_timezone(self.bot.config, self.logger)
|
|
now = datetime.now(local_tz)
|
|
|
|
today = now.strftime('%Y-%m-%d')
|
|
tomorrow = (now + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
day_after = (now + timedelta(days=2)).strftime('%Y-%m-%d')
|
|
day_after_2 = (now + timedelta(days=3)).strftime('%Y-%m-%d')
|
|
|
|
# Get day names for Day+2 and Day+3
|
|
day_after_date = now + timedelta(days=2)
|
|
day_after_2_date = now + timedelta(days=3)
|
|
day_after_name_en = day_after_date.strftime('%a') # Mon, Tue, Wed, etc.
|
|
day_after_2_name_en = day_after_2_date.strftime('%a')
|
|
# Translate day abbreviations
|
|
day_after_name = self._translate_day_abbreviation(day_after_name_en)
|
|
day_after_2_name = self._translate_day_abbreviation(day_after_2_name_en)
|
|
|
|
# Build user-friendly forecast with peak grouped by day
|
|
day_parts = []
|
|
|
|
# Calculate production hours and find peak for all days
|
|
watts = result.get('watts', {})
|
|
current_time = now.strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
# Find future peak first to know which day it belongs to
|
|
future_watts = {}
|
|
peak_time_str = None
|
|
peak_date = None
|
|
max_watts = None
|
|
|
|
if watts:
|
|
# Filter to only future timestamps
|
|
# Forecast.Solar API returns naive timestamps (no timezone)
|
|
# Based on testing, they appear to be in local time for the queried location
|
|
# Parse them as naive datetimes and assume they're in the bot's configured timezone
|
|
for timestamp, power in watts.items():
|
|
try:
|
|
# Parse as naive datetime
|
|
dt_naive = None
|
|
|
|
# Try ISO format first (with Z or timezone)
|
|
if 'Z' in timestamp or '+' in timestamp:
|
|
# Has timezone info, parse and convert
|
|
dt_with_tz = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
|
if dt_with_tz.tzinfo:
|
|
# Convert to local timezone
|
|
if local_tz:
|
|
dt_naive = dt_with_tz.astimezone(local_tz).replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz.astimezone().replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz
|
|
else:
|
|
# Naive format - parse directly
|
|
dt_naive = datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S')
|
|
|
|
# Compare with current time - convert now to naive if needed
|
|
if now.tzinfo:
|
|
# Convert timezone-aware now to naive for comparison
|
|
now_naive = now.replace(tzinfo=None)
|
|
else:
|
|
now_naive = now
|
|
|
|
if dt_naive > now_naive:
|
|
# Store with original timestamp key
|
|
future_watts[timestamp] = (power, dt_naive)
|
|
|
|
except (ValueError, TypeError) as e:
|
|
self.logger.debug(f"Error parsing timestamp {timestamp}: {e}")
|
|
pass
|
|
|
|
# Find peak from future data, but only from today or tomorrow
|
|
if future_watts:
|
|
# Filter to only today and tomorrow
|
|
today_tomorrow_watts = {}
|
|
for timestamp, (power, dt_naive) in future_watts.items():
|
|
date_str = dt_naive.strftime('%Y-%m-%d')
|
|
if date_str == today or date_str == tomorrow:
|
|
today_tomorrow_watts[timestamp] = (power, dt_naive)
|
|
|
|
# Find peak from today/tomorrow only
|
|
if today_tomorrow_watts:
|
|
max_watts = max(power for power, dt in today_tomorrow_watts.values())
|
|
for timestamp, (power, dt_naive) in today_tomorrow_watts.items():
|
|
if power == max_watts:
|
|
peak_time_str = dt_naive.strftime('%H:%M')
|
|
peak_date = dt_naive.strftime('%Y-%m-%d')
|
|
break
|
|
|
|
# Helper function to parse timestamp and get date
|
|
def get_local_date_from_timestamp(timestamp_str):
|
|
"""Parse timestamp (assumed to be in local time) and return date string"""
|
|
try:
|
|
# Parse as naive datetime (API returns local time for location)
|
|
if 'Z' in timestamp_str or '+' in timestamp_str:
|
|
# Has timezone info, parse and convert to local
|
|
dt_with_tz = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
|
|
if dt_with_tz.tzinfo:
|
|
if local_tz:
|
|
dt_naive = dt_with_tz.astimezone(local_tz).replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz.astimezone().replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz
|
|
else:
|
|
# Naive format - parse directly
|
|
dt_naive = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S')
|
|
|
|
return dt_naive.strftime('%Y-%m-%d')
|
|
except:
|
|
return None
|
|
|
|
# Calculate utilization once for first day (only show %util on first day)
|
|
first_day_utilization = None
|
|
first_day_date = None
|
|
if today in watt_hours_day:
|
|
first_day_date = today
|
|
first_day_wh = watt_hours_day[today]
|
|
first_day_prod_hours = 0
|
|
if watts:
|
|
# Convert timestamps to local dates and count production hours
|
|
# Use fixed threshold of 0.1W for all panels to ensure consistent hour counts
|
|
# This filters out noise/very low power that isn't meaningful production
|
|
min_power_threshold = 0.1
|
|
# Count unique hours (not data points) - API may have multiple points per hour
|
|
unique_hours = set()
|
|
for ts, power in watts.items():
|
|
local_date = get_local_date_from_timestamp(ts)
|
|
if local_date == today and power >= min_power_threshold:
|
|
try:
|
|
if 'Z' in ts or '+' in ts:
|
|
dt_with_tz = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
|
if dt_with_tz.tzinfo:
|
|
if local_tz:
|
|
dt_naive = dt_with_tz.astimezone(local_tz).replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz.astimezone().replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz
|
|
else:
|
|
dt_naive = datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')
|
|
hour_key = f"{local_date}_{dt_naive.hour}"
|
|
unique_hours.add(hour_key)
|
|
except:
|
|
pass
|
|
first_day_prod_hours = len(unique_hours)
|
|
|
|
if first_day_prod_hours > 0:
|
|
# Use 100% of panel capacity - API already accounts for real-world conditions
|
|
typical_max_power = panel_watts
|
|
typical_max_energy = typical_max_power * first_day_prod_hours
|
|
if typical_max_energy > 0:
|
|
first_day_utilization = (first_day_wh / typical_max_energy) * 100
|
|
elif tomorrow in watt_hours_day:
|
|
first_day_date = tomorrow
|
|
first_day_wh = watt_hours_day[tomorrow]
|
|
first_day_prod_hours = 0
|
|
if watts:
|
|
# Convert timestamps to local dates and count production hours
|
|
# Use minimum threshold: 1% of panel capacity or 0.1W, whichever is higher
|
|
min_power_threshold = max(panel_watts * 0.01, 0.1)
|
|
for ts, power in watts.items():
|
|
local_date = get_local_date_from_timestamp(ts)
|
|
if local_date == tomorrow and power >= min_power_threshold:
|
|
first_day_prod_hours += 1
|
|
|
|
if first_day_prod_hours > 0:
|
|
# Use 100% of panel capacity - API already accounts for real-world conditions
|
|
typical_max_power = panel_watts
|
|
typical_max_energy = typical_max_power * first_day_prod_hours
|
|
if typical_max_energy > 0:
|
|
first_day_utilization = (first_day_wh / typical_max_energy) * 100
|
|
|
|
# Build lines for multi-line format
|
|
lines = []
|
|
|
|
# Add panel info and location to first line
|
|
panel_info = f"{panel_watts:.0f}W"
|
|
if location_name:
|
|
abbreviated_location = abbreviate_location(location_name, max_length=25)
|
|
first_line_prefix = f"{abbreviated_location}: {panel_info} "
|
|
else:
|
|
first_line_prefix = f"{panel_info} "
|
|
|
|
# Today
|
|
if today in watt_hours_day:
|
|
today_wh = watt_hours_day[today]
|
|
today_prod_hours = 0
|
|
if watts:
|
|
# Convert timestamps to local dates and count production hours
|
|
# Use minimum threshold: 1% of panel capacity or 0.1W, whichever is higher
|
|
min_power_threshold = max(panel_watts * 0.01, 0.1)
|
|
# Count unique hours (not data points) - API may have multiple points per hour
|
|
unique_hours = set()
|
|
for ts, power in watts.items():
|
|
local_date = get_local_date_from_timestamp(ts)
|
|
if local_date == today and power >= min_power_threshold:
|
|
try:
|
|
if 'Z' in ts or '+' in ts:
|
|
dt_with_tz = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
|
if dt_with_tz.tzinfo:
|
|
if local_tz:
|
|
dt_naive = dt_with_tz.astimezone(local_tz).replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz.astimezone().replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz
|
|
else:
|
|
dt_naive = datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')
|
|
hour_key = f"{local_date}_{dt_naive.hour}"
|
|
unique_hours.add(hour_key)
|
|
except:
|
|
pass
|
|
today_prod_hours = len(unique_hours)
|
|
|
|
today_part = self.translate('commands.solarforecast.labels.today', wh=today_wh)
|
|
if today_prod_hours > 0:
|
|
if first_day_utilization is not None and first_day_date == today:
|
|
# First day - show %util
|
|
today_part += self.translate('commands.solarforecast.labels.hours_util', hours=today_prod_hours, util=first_day_utilization)
|
|
else:
|
|
# Calculate utilization for this day
|
|
# Use 100% of panel capacity - API already accounts for real-world conditions
|
|
typical_max_power = panel_watts
|
|
typical_max_energy = typical_max_power * today_prod_hours
|
|
if typical_max_energy > 0:
|
|
utilization = (today_wh / typical_max_energy) * 100
|
|
today_part += self.translate('commands.solarforecast.labels.hours_percent', hours=today_prod_hours, percent=utilization)
|
|
else:
|
|
today_part += self.translate('commands.solarforecast.labels.hours_only', hours=today_prod_hours)
|
|
|
|
# Add peak if it's today
|
|
if peak_date == today and peak_time_str:
|
|
today_part += " " + self.translate('commands.solarforecast.labels.peak', watts=max_watts, time=peak_time_str)
|
|
|
|
# First line includes panel info and location
|
|
first_line = f"{first_line_prefix}{today_part}"
|
|
lines.append(first_line)
|
|
|
|
# Tomorrow
|
|
if tomorrow in watt_hours_day:
|
|
tomorrow_wh = watt_hours_day[tomorrow]
|
|
tomorrow_prod_hours = 0
|
|
if watts:
|
|
# Convert timestamps to local dates and count production hours
|
|
# Use minimum threshold: 1% of panel capacity or 0.1W, whichever is higher
|
|
min_power_threshold = max(panel_watts * 0.01, 0.1)
|
|
# Count unique hours (not data points) - API may have multiple points per hour
|
|
unique_hours = set()
|
|
for ts, power in watts.items():
|
|
local_date = get_local_date_from_timestamp(ts)
|
|
if local_date == tomorrow and power >= min_power_threshold:
|
|
try:
|
|
if 'Z' in ts or '+' in ts:
|
|
dt_with_tz = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
|
if dt_with_tz.tzinfo:
|
|
if local_tz:
|
|
dt_naive = dt_with_tz.astimezone(local_tz).replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz.astimezone().replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz
|
|
else:
|
|
dt_naive = datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')
|
|
hour_key = f"{local_date}_{dt_naive.hour}"
|
|
unique_hours.add(hour_key)
|
|
except:
|
|
pass
|
|
tomorrow_prod_hours = len(unique_hours)
|
|
|
|
tomorrow_part = self.translate('commands.solarforecast.labels.tomorrow', wh=tomorrow_wh)
|
|
if tomorrow_prod_hours > 0:
|
|
if first_day_utilization is not None and first_day_date == tomorrow:
|
|
# First day - show %util
|
|
tomorrow_part += self.translate('commands.solarforecast.labels.hours_util', hours=tomorrow_prod_hours, util=first_day_utilization)
|
|
else:
|
|
# Calculate utilization for this day
|
|
# Use 100% of panel capacity - API already accounts for real-world conditions
|
|
typical_max_power = panel_watts
|
|
typical_max_energy = typical_max_power * tomorrow_prod_hours
|
|
if typical_max_energy > 0:
|
|
utilization = (tomorrow_wh / typical_max_energy) * 100
|
|
tomorrow_part += self.translate('commands.solarforecast.labels.hours_percent', hours=tomorrow_prod_hours, percent=utilization)
|
|
else:
|
|
tomorrow_part += self.translate('commands.solarforecast.labels.hours_only', hours=tomorrow_prod_hours)
|
|
|
|
# Add peak if it's tomorrow
|
|
if peak_date == tomorrow and peak_time_str:
|
|
tomorrow_part += " " + self.translate('commands.solarforecast.labels.peak', watts=max_watts, time=peak_time_str)
|
|
|
|
lines.append(tomorrow_part)
|
|
|
|
# Day+2 and Day+3 on same line with | separator
|
|
day_plus_line_parts = []
|
|
|
|
# Day after tomorrow (if available, 3-day forecast)
|
|
if day_after in watt_hours_day:
|
|
day_after_wh = watt_hours_day[day_after]
|
|
day_after_prod_hours = 0
|
|
if watts:
|
|
# Convert timestamps to local dates and count production hours
|
|
# Use minimum threshold: 1% of panel capacity or 0.1W, whichever is higher
|
|
min_power_threshold = max(panel_watts * 0.01, 0.1)
|
|
# Count unique hours (not data points) - API may have multiple points per hour
|
|
unique_hours = set()
|
|
for ts, power in watts.items():
|
|
local_date = get_local_date_from_timestamp(ts)
|
|
if local_date == day_after and power >= min_power_threshold:
|
|
try:
|
|
if 'Z' in ts or '+' in ts:
|
|
dt_with_tz = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
|
if dt_with_tz.tzinfo:
|
|
if local_tz:
|
|
dt_naive = dt_with_tz.astimezone(local_tz).replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz.astimezone().replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz
|
|
else:
|
|
dt_naive = datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')
|
|
hour_key = f"{local_date}_{dt_naive.hour}"
|
|
unique_hours.add(hour_key)
|
|
except:
|
|
pass
|
|
day_after_prod_hours = len(unique_hours)
|
|
|
|
day_after_part = self.translate('commands.solarforecast.labels.day_format', day=day_after_name, wh=day_after_wh)
|
|
if day_after_prod_hours > 0:
|
|
# Calculate utilization for this day
|
|
# Use 100% of panel capacity - API already accounts for real-world conditions
|
|
typical_max_power = panel_watts
|
|
typical_max_energy = typical_max_power * day_after_prod_hours
|
|
if typical_max_energy > 0:
|
|
utilization = (day_after_wh / typical_max_energy) * 100
|
|
day_after_part += self.translate('commands.solarforecast.labels.hours_percent', hours=day_after_prod_hours, percent=utilization)
|
|
else:
|
|
day_after_part += self.translate('commands.solarforecast.labels.hours_only', hours=day_after_prod_hours)
|
|
|
|
# Peak is only shown for today or tomorrow, not for later days
|
|
|
|
day_plus_line_parts.append(day_after_part)
|
|
|
|
# Day after that (if available, 4+ day forecast)
|
|
if day_after_2 in watt_hours_day:
|
|
day_after_2_wh = watt_hours_day[day_after_2]
|
|
day_after_2_prod_hours = 0
|
|
if watts:
|
|
# Convert timestamps to local dates and count production hours
|
|
# Use minimum threshold: 1% of panel capacity or 0.1W, whichever is higher
|
|
min_power_threshold = max(panel_watts * 0.01, 0.1)
|
|
# Count unique hours (not data points) - API may have multiple points per hour
|
|
unique_hours = set()
|
|
for ts, power in watts.items():
|
|
local_date = get_local_date_from_timestamp(ts)
|
|
if local_date == day_after_2 and power >= min_power_threshold:
|
|
try:
|
|
if 'Z' in ts or '+' in ts:
|
|
dt_with_tz = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
|
if dt_with_tz.tzinfo:
|
|
if local_tz:
|
|
dt_naive = dt_with_tz.astimezone(local_tz).replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz.astimezone().replace(tzinfo=None)
|
|
else:
|
|
dt_naive = dt_with_tz
|
|
else:
|
|
dt_naive = datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')
|
|
hour_key = f"{local_date}_{dt_naive.hour}"
|
|
unique_hours.add(hour_key)
|
|
except:
|
|
pass
|
|
day_after_2_prod_hours = len(unique_hours)
|
|
|
|
day_after_2_part = self.translate('commands.solarforecast.labels.day_format', day=day_after_2_name, wh=day_after_2_wh)
|
|
if day_after_2_prod_hours > 0:
|
|
# Calculate utilization for this day
|
|
# Use 100% of panel capacity - API already accounts for real-world conditions
|
|
typical_max_power = panel_watts
|
|
typical_max_energy = typical_max_power * day_after_2_prod_hours
|
|
if typical_max_energy > 0:
|
|
utilization = (day_after_2_wh / typical_max_energy) * 100
|
|
day_after_2_part += self.translate('commands.solarforecast.labels.hours_percent', hours=day_after_2_prod_hours, percent=utilization)
|
|
else:
|
|
day_after_2_part += self.translate('commands.solarforecast.labels.hours_only', hours=day_after_2_prod_hours)
|
|
|
|
# Peak is only shown for today or tomorrow, not for later days
|
|
|
|
day_plus_line_parts.append(day_after_2_part)
|
|
|
|
# Add Day+2 and Day+3 on same line if both exist
|
|
if day_plus_line_parts:
|
|
separator = self.translate('commands.solarforecast.labels.separator')
|
|
day_plus_line = separator.join(day_plus_line_parts)
|
|
lines.append(day_plus_line)
|
|
|
|
# If peak has passed, add it at the end
|
|
if not future_watts and watts:
|
|
# Find max from today's timestamps (converted to local)
|
|
today_watts = []
|
|
for ts, power in watts.items():
|
|
local_date = get_local_date_from_timestamp(ts)
|
|
if local_date == today:
|
|
today_watts.append(power)
|
|
if today_watts:
|
|
past_max = max(today_watts)
|
|
lines.append(self.translate('commands.solarforecast.labels.peak_past', watts=past_max))
|
|
|
|
# Join lines with newlines
|
|
full_message = "\n".join(lines)
|
|
|
|
return full_message
|
|
|
|
async def _send_forecast_response(self, message: MeshMessage, forecast_text: str):
|
|
"""Send forecast response, splitting into multiple messages if needed"""
|
|
import asyncio
|
|
|
|
lines = forecast_text.split('\n')
|
|
|
|
# If single line and under 130 chars, send as-is
|
|
if len(lines) == 1 and len(forecast_text) <= 130:
|
|
await self.send_response(message, forecast_text)
|
|
return
|
|
|
|
# Multi-line or long message - send each line as separate message if needed
|
|
current_message = ""
|
|
message_count = 0
|
|
|
|
for i, line in enumerate(lines):
|
|
# Check if adding this line would exceed 130 characters
|
|
if current_message:
|
|
test_message = current_message + "\n" + line
|
|
else:
|
|
test_message = line
|
|
|
|
if len(test_message) > 130:
|
|
# Send current message and start new one
|
|
if current_message:
|
|
# Per-user rate limit applies only to first message (trigger); skip for continuations
|
|
await self.send_response(
|
|
message, current_message,
|
|
skip_user_rate_limit=(message_count > 0)
|
|
)
|
|
message_count += 1
|
|
# Wait between messages (same as other commands)
|
|
if message_count > 0 and i < len(lines):
|
|
await asyncio.sleep(2.0)
|
|
|
|
current_message = line
|
|
else:
|
|
# Single line is too long, send it anyway (will be truncated by bot)
|
|
await self.send_response(
|
|
message, line,
|
|
skip_user_rate_limit=(message_count > 0)
|
|
)
|
|
message_count += 1
|
|
if i < len(lines) - 1:
|
|
await asyncio.sleep(2.0)
|
|
else:
|
|
# Add line to current message
|
|
if current_message:
|
|
current_message += "\n" + line
|
|
else:
|
|
current_message = line
|
|
|
|
# Send the last message if there's content (continuation; skip per-user rate limit)
|
|
if current_message:
|
|
await self.send_response(message, current_message, skip_user_rate_limit=True)
|
|
|