#!/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 # Parse arguments into array to handle subcommands properly # When bot.sh calls this with "$will", it arrives as a single quoted argument # e.g., "set Ferguson North Carolina" comes as one $1, not multiple args # shellcheck disable=SC2206 request=($*) # Database file for user locations and cached geocoding weatherDb="data/weather.db" weatherDbLock="${weatherDb}.lock" nominatimRateLimitFile="data/nominatim_last_request" 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" # Use flock to prevent race conditions ( flock -x 200 # 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" ) 200>"$weatherDbLock" } # Function to delete user location delete_user_location() { local user="$1" # Use flock to prevent race conditions ( flock -x 200 if [[ -f "$weatherDb" ]]; then grep -v "^USER|${user}|" "$weatherDb" > "${weatherDb}.tmp" mv "${weatherDb}.tmp" "$weatherDb" fi ) 200>"$weatherDbLock" } # Function to maintain cache size maintain_cache() { # Use flock to prevent race conditions ( flock -x 200 if [[ ! -f "$weatherDb" ]]; then return 0 fi local lineCount lineCount=$(wc -l < "$weatherDb") # If file has 2000 or more lines, keep only the most recent 1500 if [[ $lineCount -ge 2000 ]]; then # Separate USER and CACHE entries grep "^USER|" "$weatherDb" > "${weatherDb}.tmp.user" 2>/dev/null || true grep "^CACHE|" "$weatherDb" | tail -n 1000 > "${weatherDb}.tmp.cache" 2>/dev/null || true # Combine them back (keep all user entries, trim cache entries) cat "${weatherDb}.tmp.user" "${weatherDb}.tmp.cache" > "${weatherDb}.tmp" 2>/dev/null mv "${weatherDb}.tmp" "$weatherDb" # Clean up temp files rm -f "${weatherDb}.tmp.user" "${weatherDb}.tmp.cache" fi ) 200>"$weatherDbLock" } # 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 # Use flock to prevent race conditions ( flock -x 200 echo "CACHE|${queryLower}|${location}|${lat}|${lon}|${formatted}" >> "$weatherDb" ) 200>"$weatherDbLock" # Maintain cache size after adding new entry maintain_cache fi } # Function to enforce Nominatim rate limit (1 request per second) rate_limit_nominatim() { local rateLimitLock="${nominatimRateLimitFile}.lock" ( flock -x 200 # Check if rate limit file exists and read last request time if [[ -f "$nominatimRateLimitFile" ]]; then local lastRequest lastRequest=$(cat "$nominatimRateLimitFile" 2>/dev/null || echo "0") local currentTime currentTime=$(date +%s) local timeSinceLastRequest=$((currentTime - lastRequest)) # If less than 1 second has passed, sleep for the remaining time if [[ $timeSinceLastRequest -lt 1 ]]; then local sleepTime=$((1 - timeSinceLastRequest)) sleep "$sleepTime" fi fi # Update last request time date +%s > "$nominatimRateLimitFile" ) 200>"$rateLimitLock" } # 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 # Check if query contains common US indicators (case-insensitive) local countryCode="" local queryLower="${query,,}" local isUsZipCode=false # Common US state names and abbreviations local usStates="alabama|alaska|arizona|arkansas|california|colorado|connecticut|delaware|florida|georgia|hawaii|idaho|illinois|indiana|iowa|kansas|kentucky|louisiana|maine|maryland|massachusetts|michigan|minnesota|mississippi|missouri|montana|nebraska|nevada|new hampshire|new jersey|new mexico|new york|north carolina|north dakota|ohio|oklahoma|oregon|pennsylvania|rhode island|south carolina|south dakota|tennessee|texas|utah|vermont|virginia|washington|west virginia|wisconsin|wyoming" # Check if this is a US ZIP code query if [[ "$query" =~ ^[0-9]{5}([[:space:]]|$) ]]; then isUsZipCode=true fi if [[ "$queryLower" =~ (usa|united states) ]] || \ [[ "$query" =~ [[:space:]][A-Z]{2}$ ]] || \ [[ "$queryLower" =~ [[:space:]](${usStates})$ ]] || \ [[ "$isUsZipCode" == true ]]; then # Query mentions USA, ends with state abbreviation, ends with state name, or is a zip code countryCode="&countrycodes=us" fi # Build the query URL local apiUrl if [[ "$isUsZipCode" == true ]]; then # For ZIP codes, use postalcode parameter for better accuracy local zipCode="${query%% *}" # Extract just the ZIP code (remove any trailing text) apiUrl="${url}?postalcode=${zipCode}&country=us&format=json&limit=1&addressdetails=1" else # URL encode the query (replace spaces with +) local encodedQuery="${query// /+}" apiUrl="${url}?q=${encodedQuery}&format=json&limit=1&addressdetails=1${countryCode}" fi # Enforce rate limit before making API request rate_limit_nominatim response=$(curl -s --connect-timeout 5 --max-time 10 \ -H "User-Agent: ${userAgent}" \ "${apiUrl}") 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,apparent_temperature,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 feelsLike local humidity local windSpeed local weatherCode temp=$(echo "$weatherData" | jq -r '.current.temperature_2m // "N/A"') feelsLike=$(echo "$weatherData" | jq -r '.current.apparent_temperature // "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" # Add feels-like if different from actual temp (round to nearest degree for comparison) if [[ "$feelsLike" != "N/A" && "$temp" != "N/A" ]]; then local tempRounded local feelsLikeRounded tempRounded=$(printf "%.0f" "$temp" 2>/dev/null || echo "$temp") feelsLikeRounded=$(printf "%.0f" "$feelsLike" 2>/dev/null || echo "$feelsLike") # Only show feels-like if it differs by at least 3 degrees local diff=$((tempRounded - feelsLikeRounded)) if [[ ${diff#-} -ge 3 ]]; then message+=" (feels like ${feelsLike}°F)" fi fi message+=", ${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 # Check if first element is a subcommand subcommand="${request[0]:-}" case "$subcommand" in set) # Set user's location # Remove 'set' from the array and get the rest as location if [[ ${#request[@]} -lt 2 ]]; then msg "$channelName" "$name: Usage: weather set " exit 0 fi # Get everything after 'set' as the location location="${request[*]:1}" # 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}" exit 0 ;; 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="${request[*]}" # 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 ' to save your location, or 'weather ' 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