441 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Bash
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			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+="¤t=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
 |