Files
stormbot/modules/weather/weather.sh
2025-10-24 17:14:52 -04:00

441 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
[ -f functions.sh ] && source functions.sh
# Dependencies required by this module
dependencies=("curl" "jq" "date")
if ! check_dependencies "${dependencies[@]}"; then
msg "$2" "$1: This module requires: ${dependencies[*]}"
exit 1
fi
set -f
name="$1"
channelName="$2"
shift 2
# Database file for user locations and cached geocoding
weatherDb="data/weather.db"
mkdir -p "$(dirname "$weatherDb")"
touch "$weatherDb"
# Weather code mapping (Open-Meteo codes to descriptions)
declare -A weatherCodes=(
[0]="Clear sky"
[1]="Mainly clear"
[2]="Partly cloudy"
[3]="Overcast"
[45]="Fog"
[48]="Rime fog"
[51]="Light drizzle"
[53]="Moderate drizzle"
[55]="Dense drizzle"
[56]="Light freezing drizzle"
[57]="Dense freezing drizzle"
[61]="Slight rain"
[63]="Moderate rain"
[65]="Heavy rain"
[66]="Light freezing rain"
[67]="Heavy freezing rain"
[71]="Slight snow fall"
[73]="Moderate snow fall"
[75]="Heavy snow fall"
[77]="Snow flurries"
[80]="Slight rain showers"
[81]="Moderate rain showers"
[82]="Heavy rain showers"
[85]="Slight snow showers"
[86]="Heavy snow showers"
[95]="Thunderstorm"
[96]="Thunderstorm with slight hail"
[99]="Thunderstorm with heavy hail"
)
# Function to get cached geocode result
get_cached_location() {
local query="$1"
local queryLower="${query,,}"
grep -i "^CACHE|${queryLower}|" "$weatherDb" 2>/dev/null | head -n1
}
# Function to get user's saved location
get_user_location() {
local user="$1"
grep "^USER|${user}|" "$weatherDb" 2>/dev/null | head -n1
}
# Function to save user location
save_user_location() {
local user="$1"
local location="$2"
local lat="$3"
local lon="$4"
local formatted="$5"
# Remove old entry if exists
if [[ -f "$weatherDb" ]]; then
grep -v "^USER|${user}|" "$weatherDb" > "${weatherDb}.tmp"
mv "${weatherDb}.tmp" "$weatherDb"
fi
# Add new entry
echo "USER|${user}|${location}|${lat}|${lon}|${formatted}" >> "$weatherDb"
}
# Function to delete user location
delete_user_location() {
local user="$1"
if [[ -f "$weatherDb" ]]; then
grep -v "^USER|${user}|" "$weatherDb" > "${weatherDb}.tmp"
mv "${weatherDb}.tmp" "$weatherDb"
fi
}
# Function to cache geocode result
cache_location() {
local query="$1"
local location="$2"
local lat="$3"
local lon="$4"
local formatted="$5"
local queryLower="${query,,}"
# Check if already cached
local cached
cached=$(get_cached_location "$query")
if [[ -z "$cached" ]]; then
echo "CACHE|${queryLower}|${location}|${lat}|${lon}|${formatted}" >> "$weatherDb"
fi
}
# Function to format location (City, State or City, Country)
format_location() {
local address="$1"
# Use jq to parse the address components
local locality
local state
local country
local countryCode
# Try various location types in order of specificity
locality=$(echo "$address" | jq -r '.city // .town // .village // .hamlet // .municipality // .suburb // empty' 2>/dev/null)
state=$(echo "$address" | jq -r '.state // empty' 2>/dev/null)
country=$(echo "$address" | jq -r '.country // empty' 2>/dev/null)
countryCode=$(echo "$address" | jq -r '.country_code // empty' 2>/dev/null)
# For US addresses, always show state if available
if [[ "$countryCode" == "us" && -n "$locality" && -n "$state" ]]; then
echo "${locality}, ${state}"
elif [[ "$countryCode" == "us" && -n "$state" ]]; then
# No locality, just show state
echo "${state}, United States"
elif [[ -n "$locality" && -n "$country" ]]; then
# International: show locality and country
echo "${locality}, ${country}"
elif [[ -n "$country" ]]; then
# Just country
echo "$country"
else
# Fallback to display_name if parsing fails
echo "$address" | jq -r '.display_name // "Unknown location"' 2>/dev/null
fi
}
# Function to geocode a location
geocode_location() {
local query="$1"
# Check cache first
local cached
local location
local lat
local lon
local formatted
cached=$(get_cached_location "$query")
if [[ -n "$cached" ]]; then
# Format: CACHE|query|location|lat|lon|formatted
location=$(echo "$cached" | cut -d'|' -f3)
lat=$(echo "$cached" | cut -d'|' -f4)
lon=$(echo "$cached" | cut -d'|' -f5)
formatted=$(echo "$cached" | cut -d'|' -f6)
# If no formatted location in cache (old format), use location
if [[ -z "$formatted" ]]; then
formatted="$location"
fi
echo "${location}|${lat}|${lon}|${formatted}"
return 0
fi
# Query Nominatim API
local url="https://nominatim.openstreetmap.org/search"
local userAgent="stormbot-weather/1.0"
local response
# URL encode the query (replace spaces with +)
local encodedQuery="${query// /+}"
response=$(curl -s --connect-timeout 5 --max-time 10 \
-H "User-Agent: ${userAgent}" \
"${url}?q=${encodedQuery}&format=json&limit=1&addressdetails=1")
if [[ -z "$response" || "$response" == "[]" ]]; then
return 1
fi
# Parse response
local fullAddress
local addressDetails
local formattedLocation
lat=$(echo "$response" | jq -r '.[0].lat // empty' 2>/dev/null)
lon=$(echo "$response" | jq -r '.[0].lon // empty' 2>/dev/null)
fullAddress=$(echo "$response" | jq -r '.[0].display_name // empty' 2>/dev/null)
addressDetails=$(echo "$response" | jq -r '.[0].address // empty' 2>/dev/null)
if [[ -z "$lat" || -z "$lon" ]]; then
return 1
fi
# Format the location nicely
formattedLocation=$(format_location "$addressDetails")
# Cache the result
cache_location "$query" "$fullAddress" "$lat" "$lon" "$formattedLocation"
echo "${fullAddress}|${lat}|${lon}|${formattedLocation}"
return 0
}
# Function to get weather data
get_weather() {
local lat="$1"
local lon="$2"
local url="https://api.open-meteo.com/v1/forecast"
local params="latitude=${lat}&longitude=${lon}"
params+="&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m"
params+="&daily=weather_code,temperature_2m_max,temperature_2m_min"
params+="&timezone=auto&forecast_days=3&temperature_unit=fahrenheit&wind_speed_unit=mph"
local response
response=$(curl -s --connect-timeout 5 --max-time 10 "${url}?${params}")
if [[ -z "$response" ]]; then
return 1
fi
echo "$response"
return 0
}
# Function to format weather output
format_weather() {
local weatherData="$1"
local locationName="$2"
# Parse current weather
local temp
local humidity
local windSpeed
local weatherCode
temp=$(echo "$weatherData" | jq -r '.current.temperature_2m // "N/A"')
humidity=$(echo "$weatherData" | jq -r '.current.relative_humidity_2m // "N/A"')
windSpeed=$(echo "$weatherData" | jq -r '.current.wind_speed_10m // "N/A"')
weatherCode=$(echo "$weatherData" | jq -r '.current.weather_code // 0')
local conditions="${weatherCodes[$weatherCode]:-Unknown}"
# Build message
local message="Weather for ${locationName}: ${temp}°F, ${conditions}"
if [[ "$humidity" != "N/A" ]]; then
message+=", Humidity: ${humidity}%"
fi
if [[ "$windSpeed" != "N/A" ]]; then
message+=", Wind: ${windSpeed} mph"
fi
echo "$message"
}
# Function to format forecast output
format_forecast() {
local weatherData="$1"
# Parse forecast data
local dates
dates=$(echo "$weatherData" | jq -r '.daily.time[]' 2>/dev/null)
if [[ -z "$dates" ]]; then
return 1
fi
local forecastParts=()
local i=0
while IFS= read -r dateStr; do
local minTemp
local maxTemp
local code
minTemp=$(echo "$weatherData" | jq -r ".daily.temperature_2m_min[$i] // \"N/A\"")
maxTemp=$(echo "$weatherData" | jq -r ".daily.temperature_2m_max[$i] // \"N/A\"")
code=$(echo "$weatherData" | jq -r ".daily.weather_code[$i] // 0")
# Format date to day of week
local dayName
if date --version &>/dev/null; then
# GNU date
dayName=$(date -d "$dateStr" '+%A' 2>/dev/null)
else
# BSD date (macOS)
dayName=$(date -j -f '%Y-%m-%d' "$dateStr" '+%A' 2>/dev/null)
fi
if [[ -z "$dayName" ]]; then
dayName="$dateStr"
fi
local forecastConditions="${weatherCodes[$code]:-Unknown}"
if [[ "$minTemp" != "N/A" && "$maxTemp" != "N/A" ]]; then
forecastParts+=("${dayName}: ${minTemp}°F to ${maxTemp}°F, ${forecastConditions}")
fi
((i++))
# Only show 3 days
if [[ $i -ge 3 ]]; then
break
fi
done <<< "$dates"
if [[ ${#forecastParts[@]} -gt 0 ]]; then
local IFS=" | "
echo "Forecast: ${forecastParts[*]}"
return 0
fi
return 1
}
# Main command logic
subcommand="${1:-}"
# If subcommand is the module name itself (w or weather), treat as no argument
if [[ "$subcommand" == "w" || "$subcommand" == "weather" ]]; then
subcommand=""
fi
case "$subcommand" in
set)
# Set user's location
shift
if [[ $# -eq 0 ]]; then
msg "$channelName" "$name: Usage: weather set <location>"
exit 0
fi
location="$*"
# Validate location length
if [[ ${#location} -gt 100 ]]; then
msg "$channelName" "$name: Location is too long (max 100 characters)."
exit 0
fi
# Geocode the location
geocodeResult=$(geocode_location "$location")
if [[ $? -ne 0 || -z "$geocodeResult" ]]; then
msg "$channelName" "$name: Could not find that location. Please try a different search term."
exit 0
fi
# Parse result: fullAddress|lat|lon|formattedLocation
fullAddress=$(echo "$geocodeResult" | cut -d'|' -f1)
lat=$(echo "$geocodeResult" | cut -d'|' -f2)
lon=$(echo "$geocodeResult" | cut -d'|' -f3)
formattedLocation=$(echo "$geocodeResult" | cut -d'|' -f4)
# Save user location
save_user_location "$name" "$fullAddress" "$lat" "$lon" "$formattedLocation"
msg "$channelName" "$name: Your location has been set to: ${formattedLocation}"
;;
del|delete)
# Delete user's location
delete_user_location "$name"
msg "$channelName" "$name: Your location has been deleted."
;;
*)
# Show weather (default action)
if [[ -n "$subcommand" ]]; then
# Weather for specified location
location="$*"
# Validate location length
if [[ ${#location} -gt 100 ]]; then
msg "$channelName" "$name: Location is too long (max 100 characters)."
exit 0
fi
# Geocode the location
geocodeResult=$(geocode_location "$location")
if [[ $? -ne 0 || -z "$geocodeResult" ]]; then
msg "$channelName" "$name: Could not find that location. Please try a different search term."
exit 0
fi
# Parse result
fullAddress=$(echo "$geocodeResult" | cut -d'|' -f1)
lat=$(echo "$geocodeResult" | cut -d'|' -f2)
lon=$(echo "$geocodeResult" | cut -d'|' -f3)
formattedLocation=$(echo "$geocodeResult" | cut -d'|' -f4)
locationDisplay="$formattedLocation"
else
# Weather for user's saved location
userLocation=$(get_user_location "$name")
if [[ -z "$userLocation" ]]; then
msg "$channelName" "$name: You have not set a location. Use 'weather set <location>' to save your location, or 'weather <location>' to check weather for a specific location."
exit 0
fi
# Parse user location: USER|name|location|lat|lon|formatted
fullAddress=$(echo "$userLocation" | cut -d'|' -f3)
lat=$(echo "$userLocation" | cut -d'|' -f4)
lon=$(echo "$userLocation" | cut -d'|' -f5)
formattedLocation=$(echo "$userLocation" | cut -d'|' -f6)
# If no formatted location stored (old format), use full address
if [[ -z "$formattedLocation" ]]; then
formattedLocation="$fullAddress"
fi
locationDisplay="$formattedLocation"
fi
# Get weather data
weatherData=$(get_weather "$lat" "$lon")
if [[ $? -ne 0 || -z "$weatherData" ]]; then
msg "$channelName" "$name: Could not retrieve weather data. Please try again later."
exit 1
fi
# Format and send current weather
weatherMessage=$(format_weather "$weatherData" "$locationDisplay")
msg "$channelName" "$name: ${weatherMessage}"
# Format and send forecast
forecastMessage=$(format_forecast "$weatherData")
if [[ $? -eq 0 && -n "$forecastMessage" ]]; then
msg "$channelName" "$name: ${forecastMessage}"
fi
;;
esac