mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-03-30 12:05:38 +00:00
- Standardized the configuration keys for various commands by replacing specific `*_enabled` keys with a unified `enabled` key across configuration files. - Updated command classes to support fallback mechanisms for legacy configuration keys, ensuring backward compatibility. - Enhanced the logic in the `BaseCommand` class to handle both standard and legacy keys for command enabling. - Added tests to verify the correct behavior of the new configuration handling and legacy support for commands including Stats, Sports, Hacker, and Alert.
1089 lines
48 KiB
Python
1089 lines
48 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Alert command for the MeshCore Bot
|
|
Provides PulsePoint incident alerts for locations, zip codes, and street addresses
|
|
"""
|
|
|
|
import re
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import requests
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, List, Dict, Tuple
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
from cryptography.hazmat.backends import default_backend
|
|
|
|
from .base_command import BaseCommand
|
|
from ..models import MeshMessage
|
|
from ..utils import geocode_zipcode_sync, geocode_city_sync, calculate_distance, rate_limited_nominatim_reverse_sync
|
|
|
|
# Incident type codes -> human readable (short versions for mesh)
|
|
CALL_TYPES = {
|
|
"AA": "Auto Aid", "MU": "Mutual Aid", "ST": "Strike Team",
|
|
"AC": "Aircraft Crash", "AE": "Aircraft Emerg", "AES": "Aircraft Standby", "LZ": "Landing Zone",
|
|
"AED": "AED Alarm", "OA": "Alarm", "CMA": "CO Alarm", "FA": "Fire Alarm",
|
|
"MA": "Manual Alarm", "SD": "Smoke Detector", "TRBL": "Trouble Alarm", "WFA": "Waterflow",
|
|
"FL": "Flooding", "LR": "Ladder Req", "LA": "Lift Assist",
|
|
"PA": "Police Assist", "PS": "Public Svc", "SH": "Hydrant",
|
|
"EX": "Explosion", "PE": "Pipeline Emerg", "TE": "Transformer",
|
|
"AF": "Appliance Fire", "CHIM": "Chimney Fire", "CF": "Commercial Fire",
|
|
"WSF": "Structure Fire", "WVEG": "Veg Fire", "CB": "Controlled Burn",
|
|
"ELF": "Electrical Fire", "EF": "Extinguished", "FIRE": "Fire",
|
|
"FULL": "Full Assignment", "IF": "Illegal Fire", "MF": "Marine Fire",
|
|
"OF": "Outside Fire", "PF": "Pole Fire", "GF": "Garbage Fire",
|
|
"RF": "Residential Fire", "SF": "Structure Fire", "VEG": "Veg Fire",
|
|
"VF": "Vehicle Fire", "WCF": "Working Comm Fire", "WRF": "Working Res Fire",
|
|
"BT": "Bomb Threat", "EE": "Electrical Emerg", "EM": "Emergency",
|
|
"ER": "Emergency", "GAS": "Gas Leak", "HC": "Hazmat",
|
|
"HMR": "Hazmat", "TD": "Tree Down", "WE": "Water Emerg",
|
|
"AI": "Arson Inv", "HMI": "Hazmat Inv", "INV": "Investigation",
|
|
"OI": "Odor Inv", "SI": "Smoke Inv",
|
|
"LO": "Lockout", "CL": "Comm Lockout", "RL": "Res Lockout", "VL": "Vehicle Lockout",
|
|
"IFT": "Med Transfer", "ME": "Medical", "MCI": "Mass Casualty",
|
|
"EQ": "Earthquake", "FLW": "Flood Warn", "TOW": "Tornado Warn", "TSW": "Tsunami Warn",
|
|
"CA": "Community", "FW": "Fire Watch", "NO": "Notification",
|
|
"STBY": "Standby", "TEST": "Test", "TRNG": "Training", "UNK": "Unknown",
|
|
"AR": "Animal Rescue", "CR": "Cliff Rescue", "CSR": "Confined Space",
|
|
"ELR": "Elevator Rescue", "RES": "Rescue", "RR": "Rope Rescue",
|
|
"TR": "Tech Rescue", "TNR": "Trench Rescue", "USAR": "Urban SAR",
|
|
"VS": "Vessel Sinking", "WR": "Water Rescue",
|
|
"TCE": "Major TC", "RTE": "Train Emerg",
|
|
"TC": "Traffic Collision", "TCS": "TC w/Structure", "TCT": "TC w/Train",
|
|
"WA": "Wires Arcing", "WD": "Wires Down"
|
|
}
|
|
|
|
# Unit dispatch status codes
|
|
UNIT_STATUS = {
|
|
"DP": "Dispatched",
|
|
"ER": "Enroute",
|
|
"OS": "On Scene",
|
|
"AV": "Available",
|
|
"TR": "Transport",
|
|
"TA": "Arrived",
|
|
"CL": "Cleared"
|
|
}
|
|
|
|
|
|
def _derive_key(salt: bytes) -> bytes:
|
|
"""Derive AES key from the obfuscated password.
|
|
|
|
Args:
|
|
salt: The salt bytes to use for derivation.
|
|
|
|
Returns:
|
|
bytes: The derived 32-byte key.
|
|
"""
|
|
e = "CommonIncidents"
|
|
password = e[13] + e[1] + e[2] + "brady" + "5" + "r" + e.lower()[6] + e[5] + "gs"
|
|
|
|
hasher = hashlib.md5()
|
|
key = b''
|
|
block = None
|
|
while len(key) < 32:
|
|
if block:
|
|
hasher.update(block)
|
|
hasher.update(password.encode())
|
|
hasher.update(salt)
|
|
block = hasher.digest()
|
|
hasher = hashlib.md5()
|
|
key += block
|
|
return key[:32]
|
|
|
|
|
|
def _decrypt(data: dict) -> dict:
|
|
"""Decrypt PulsePoint's encrypted response.
|
|
|
|
Args:
|
|
data: The encrypted data dictionary from the API.
|
|
|
|
Returns:
|
|
dict: The decrypted JSON data.
|
|
"""
|
|
ct = base64.b64decode(data["ct"])
|
|
iv = bytes.fromhex(data["iv"])
|
|
salt = bytes.fromhex(data["s"])
|
|
|
|
key = _derive_key(salt)
|
|
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
|
decryptor = cipher.decryptor()
|
|
out = decryptor.update(ct) + decryptor.finalize()
|
|
|
|
out = out[1:out.rindex(b'"')].decode()
|
|
out = out.replace(r'\"', r'"')
|
|
return json.loads(out)
|
|
|
|
|
|
def _parse_time(iso_str: str) -> Optional[datetime]:
|
|
"""Parse ISO timestamp to datetime and convert to local time.
|
|
|
|
Args:
|
|
iso_str: ISO formatted timestamp string.
|
|
|
|
Returns:
|
|
Optional[datetime]: Parsed timezone-aware datetime, or None if invalid.
|
|
"""
|
|
if not iso_str:
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(iso_str.replace('Z', '+00:00'))
|
|
# Convert to local time if timezone-aware
|
|
if dt.tzinfo is not None:
|
|
dt = dt.astimezone()
|
|
return dt
|
|
except:
|
|
return None
|
|
|
|
|
|
def _time_ago(dt: datetime) -> str:
|
|
"""Format datetime as relative time string (e.g., '5m ago').
|
|
|
|
Args:
|
|
dt: The datetime to compare against current time.
|
|
|
|
Returns:
|
|
str: Relative time string.
|
|
"""
|
|
if not dt:
|
|
return ""
|
|
|
|
# Use local time for comparison
|
|
now = datetime.now().astimezone()
|
|
# Ensure dt is timezone-aware (should be after _parse_time conversion)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=now.tzinfo)
|
|
diff = now - dt
|
|
mins = int(diff.total_seconds() / 60)
|
|
|
|
if mins < 1:
|
|
return "now"
|
|
elif mins < 60:
|
|
return f"{mins}m ago"
|
|
elif mins < 1440:
|
|
return f"{mins // 60}h {mins % 60}m ago"
|
|
else:
|
|
return f"{mins // 1440}d ago"
|
|
|
|
|
|
class AlertCommand(BaseCommand):
|
|
"""Handles alert/incident commands using PulsePoint API.
|
|
|
|
Retrieves and displays active fire and emergency incidents for specified
|
|
locations (city, county, zipcode, or coordinates).
|
|
"""
|
|
|
|
# Plugin metadata
|
|
name = "alert"
|
|
keywords = ['alert', 'alerts', 'incident', 'incidents']
|
|
description = "Get active emergency incidents (usage: alert seattle, alert 98258, alert 178th seattle, alert seattle all)"
|
|
category = "emergency"
|
|
cooldown_seconds = 10 # 10 second cooldown to prevent API abuse
|
|
|
|
# Documentation
|
|
short_description = "Get active emergency incidents from PulsePoint"
|
|
usage = "alert <location> [all]"
|
|
examples = ["alert seattle", "alert 98101 all"]
|
|
parameters = [
|
|
{"name": "location", "description": "City, zip code, or street address"},
|
|
{"name": "all", "description": "Show all incidents (not just nearby)"}
|
|
]
|
|
requires_internet = True # Requires internet access for PulsePoint API
|
|
|
|
def __init__(self, bot):
|
|
super().__init__(bot)
|
|
self.url_timeout = 10
|
|
self.db_manager = bot.db_manager
|
|
|
|
# Load agencies from config (separate cities and counties)
|
|
self.city_agencies, self.county_agencies = self._load_agencies()
|
|
|
|
# Get max distance from config (default 20km, about 12 miles)
|
|
self.max_distance_km = self.get_config_value('Alert_Command', 'max_distance_km', fallback=20.0, value_type='float')
|
|
|
|
# Get max incident age in hours (default 24 hours) - filter out incidents older than this
|
|
self.max_incident_age_hours = self.get_config_value('Alert_Command', 'max_incident_age_hours', fallback=24.0, value_type='float')
|
|
|
|
# Load enabled (standard enabled; alert_enabled legacy)
|
|
self.alert_enabled = self.get_config_value('Alert_Command', 'enabled', fallback=None, value_type='bool')
|
|
if self.alert_enabled is None:
|
|
self.alert_enabled = self.get_config_value('Alert_Command', 'alert_enabled', fallback=True, value_type='bool')
|
|
|
|
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.alert_enabled:
|
|
return False
|
|
|
|
# Call parent can_execute() which includes channel checking, cooldown, etc.
|
|
return super().can_execute(message)
|
|
|
|
def _load_agencies(self) -> Tuple[Dict[str, str], Dict[str, str]]:
|
|
"""Load agency IDs from config, separating cities and counties.
|
|
|
|
Returns:
|
|
Tuple[Dict[str, str], Dict[str, str]]: Tuple of (cities_map, counties_map).
|
|
"""
|
|
cities = {}
|
|
counties = {}
|
|
if self.bot.config.has_section('Alert_Command'):
|
|
for key, value in self.bot.config.items('Alert_Command'):
|
|
# New format: agency.city.seattle or agency.county.king
|
|
if key.startswith('agency.city.'):
|
|
city = key.replace('agency.city.', '').lower()
|
|
cities[city] = value.strip()
|
|
elif key.startswith('agency.county.'):
|
|
county = key.replace('agency.county.', '').lower()
|
|
counties[county] = value.strip()
|
|
# Legacy format support: agency.* (treat as county for backward compatibility)
|
|
elif key.startswith('agency.'):
|
|
name = key.replace('agency.', '').lower()
|
|
counties[name] = value.strip()
|
|
# Old format: agency_* (treat as county for backward compatibility)
|
|
elif key.startswith('agency_'):
|
|
name = key.replace('agency_', '').lower()
|
|
counties[name] = value.strip()
|
|
return cities, counties
|
|
|
|
def _normalize_location_key(self, location: str) -> str:
|
|
"""Normalize location name to match config key format (spaces -> underscores).
|
|
|
|
Args:
|
|
location: The raw location string.
|
|
|
|
Returns:
|
|
str: Normalized location string.
|
|
"""
|
|
return location.lower().replace(' ', '_')
|
|
|
|
def _get_agency_ids(self, location: str = None, location_type: str = None) -> Optional[str]:
|
|
"""Get agency IDs for a city or county, or default to all configured agencies.
|
|
|
|
Args:
|
|
location: Name of the city or county.
|
|
location_type: Type of location ('city' or 'county').
|
|
|
|
Returns:
|
|
Optional[str]: Comma-separated agency IDs, or None if specific location not found.
|
|
"""
|
|
if location:
|
|
location_lower = location.lower()
|
|
location_normalized = self._normalize_location_key(location)
|
|
|
|
# If location_type is specified, only check that type
|
|
if location_type == "city":
|
|
# Try normalized first (with underscore), then original (with space)
|
|
if location_normalized in self.city_agencies:
|
|
return self.city_agencies[location_normalized]
|
|
if location_lower in self.city_agencies:
|
|
return self.city_agencies[location_lower]
|
|
# City not found in config, return None to indicate we should use all agencies
|
|
return None
|
|
elif location_type == "county":
|
|
# Try normalized first (with underscore), then original (with space)
|
|
if location_normalized in self.county_agencies:
|
|
return self.county_agencies[location_normalized]
|
|
if location_lower in self.county_agencies:
|
|
return self.county_agencies[location_lower]
|
|
# County not found, check aliases
|
|
aliases = {
|
|
'sno': 'snohomish',
|
|
'sea': 'king', # 'sea' alias maps to King County
|
|
'tac': 'pierce',
|
|
'all': 'puget_sound'
|
|
}
|
|
if location_lower in aliases:
|
|
alias_target = aliases[location_lower]
|
|
if alias_target in self.county_agencies:
|
|
return self.county_agencies[alias_target]
|
|
return None
|
|
|
|
# If no location_type specified, check both (city first, then county)
|
|
# Try normalized first (with underscore), then original (with space)
|
|
if location_normalized in self.city_agencies:
|
|
return self.city_agencies[location_normalized]
|
|
if location_lower in self.city_agencies:
|
|
return self.city_agencies[location_lower]
|
|
if location_normalized in self.county_agencies:
|
|
return self.county_agencies[location_normalized]
|
|
if location_lower in self.county_agencies:
|
|
return self.county_agencies[location_lower]
|
|
|
|
# Check aliases (these map to counties)
|
|
aliases = {
|
|
'sno': 'snohomish',
|
|
'sea': 'king', # 'sea' alias maps to King County
|
|
'tac': 'pierce',
|
|
'all': 'puget_sound'
|
|
}
|
|
if location_lower in aliases:
|
|
alias_target = aliases[location_lower]
|
|
if alias_target in self.county_agencies:
|
|
return self.county_agencies[alias_target]
|
|
|
|
# Default: combine all agencies from both cities and counties
|
|
all_agencies = []
|
|
for agency_list in list(self.city_agencies.values()) + list(self.county_agencies.values()):
|
|
all_agencies.append(agency_list)
|
|
return ",".join(all_agencies)
|
|
|
|
def _fetch_incidents(self, agency_ids: str) -> List[Dict]:
|
|
"""Fetch active incidents from PulsePoint.
|
|
|
|
Args:
|
|
agency_ids: Comma-separated string of agency IDs.
|
|
|
|
Returns:
|
|
List[Dict]: List of incident dictionaries.
|
|
"""
|
|
url = "https://api.pulsepoint.org/v1/webapp"
|
|
params = {"resource": "incidents", "agencyid": agency_ids}
|
|
headers = {
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
"Origin": "https://web.pulsepoint.org",
|
|
"Referer": "https://web.pulsepoint.org/"
|
|
}
|
|
|
|
try:
|
|
resp = requests.get(url, params=params, headers=headers, timeout=self.url_timeout)
|
|
resp.raise_for_status()
|
|
|
|
encrypted = resp.json()
|
|
decrypted = _decrypt(encrypted)
|
|
|
|
incidents = []
|
|
seen_ids = set() # Track incident IDs to avoid duplicates
|
|
|
|
# Only fetch active incidents (not recent/cleared)
|
|
# Filter by age to exclude very old "active" incidents
|
|
now = datetime.now(timezone.utc)
|
|
max_age = self.max_incident_age_hours * 3600 # Convert hours to seconds
|
|
|
|
for inc in decrypted.get("incidents", {}).get("active", []):
|
|
incident_id = inc.get("ID")
|
|
|
|
# Skip if we've already seen this incident ID (deduplication)
|
|
if incident_id in seen_ids:
|
|
continue
|
|
seen_ids.add(incident_id)
|
|
|
|
call_type = inc.get("PulsePointIncidentCallType", "UNK")
|
|
call_time = _parse_time(inc.get("CallReceivedDateTime"))
|
|
|
|
# Filter out incidents older than max_incident_age_hours
|
|
if call_time:
|
|
# Ensure timezone-aware for comparison
|
|
if call_time.tzinfo is None:
|
|
call_time = call_time.replace(tzinfo=timezone.utc)
|
|
else:
|
|
call_time = call_time.astimezone(timezone.utc)
|
|
|
|
age_seconds = (now - call_time).total_seconds()
|
|
if age_seconds > max_age:
|
|
# Incident is too old, skip it
|
|
continue
|
|
|
|
# Parse units with status
|
|
units = []
|
|
for u in inc.get("Unit", []):
|
|
unit_id = u.get("UnitID", "?")
|
|
status = u.get("PulsePointDispatchStatus", "?")
|
|
units.append({
|
|
"id": unit_id,
|
|
"status_code": status,
|
|
"status": UNIT_STATUS.get(status, status)
|
|
})
|
|
|
|
# Parse address
|
|
full_addr = inc.get("FullDisplayAddress", "Unknown")
|
|
# Try splitting on ", " first (most common), then on "," if that fails
|
|
if ", " in full_addr:
|
|
addr_parts = full_addr.split(", ", 1)
|
|
elif "," in full_addr:
|
|
addr_parts = full_addr.split(",", 1)
|
|
else:
|
|
addr_parts = [full_addr]
|
|
street = addr_parts[0].strip()
|
|
city = addr_parts[1].strip() if len(addr_parts) > 1 else ""
|
|
|
|
incidents.append({
|
|
"id": incident_id,
|
|
"type_code": call_type,
|
|
"type": CALL_TYPES.get(call_type, call_type),
|
|
"address": full_addr,
|
|
"street": street,
|
|
"city": city,
|
|
"latitude": float(inc.get("Latitude", 0)),
|
|
"longitude": float(inc.get("Longitude", 0)),
|
|
"agency": inc.get("AgencyID"),
|
|
"time": call_time,
|
|
"time_ago": _time_ago(call_time),
|
|
"units": units,
|
|
"unit_ids": [u["id"] for u in units],
|
|
"raw": inc
|
|
})
|
|
|
|
return incidents
|
|
except Exception as e:
|
|
self.logger.error(f"Error fetching PulsePoint incidents: {e}")
|
|
return []
|
|
|
|
def _parse_query(self, query: str) -> Tuple[str, Optional[str], Optional[float], Optional[float]]:
|
|
"""Parse query string to determine search type.
|
|
|
|
Args:
|
|
query: The raw query string from the user.
|
|
|
|
Returns:
|
|
Tuple[str, Optional[str], Optional[float], Optional[float]]:
|
|
Tuple of (query_type, location, lat, lon).
|
|
query_type can be: "zipcode", "coordinates", "street_city", "city", "county".
|
|
"""
|
|
query = query.strip()
|
|
|
|
# Check for coordinates (lat,lon or lat, lon)
|
|
coord_match = re.match(r'^(-?\d+\.?\d*),?\s*(-?\d+\.?\d*)$', query)
|
|
if coord_match:
|
|
try:
|
|
lat = float(coord_match.group(1))
|
|
lon = float(coord_match.group(2))
|
|
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
|
return ("coordinates", None, lat, lon)
|
|
except:
|
|
pass
|
|
|
|
# Check for zipcode (5 digits)
|
|
if re.match(r'^\d{5}$', query):
|
|
return ("zipcode", query, None, None)
|
|
|
|
# Check for street + city pattern (e.g., "178th seattle", "main seattle")
|
|
# If query has 2+ words, check if first word looks like a street name
|
|
parts = query.split()
|
|
if len(parts) >= 2:
|
|
first_word = parts[0].lower()
|
|
|
|
# Check if first word looks like a street name:
|
|
# - Ends with a number (e.g., "178th", "5th", "123rd")
|
|
# - Ends with street suffix (e.g., "main", "oak", "park" - but these are ambiguous)
|
|
# - Is a known street prefix (e.g., "ne", "nw", "se", "sw" for directional)
|
|
looks_like_street = (
|
|
bool(re.search(r'\d+(st|nd|rd|th)$', first_word)) or # Ends with number + ordinal
|
|
first_word in ['ne', 'nw', 'se', 'sw', 'n', 's', 'e', 'w'] or # Directional prefixes
|
|
first_word.endswith(('st', 'nd', 'rd', 'th')) # Ordinal suffix
|
|
)
|
|
|
|
if looks_like_street:
|
|
# First word looks like a street, try to split into street and city
|
|
for i in range(1, len(parts)):
|
|
street_part = ' '.join(parts[:i])
|
|
city_part = ' '.join(parts[i:])
|
|
|
|
# Check if city_part looks like a city name (not a street suffix)
|
|
# If city_part is a known city/county, or doesn't end with street suffix, it's likely a city
|
|
city_lower = city_part.lower()
|
|
if (city_lower in self.city_agencies or
|
|
city_lower in self.county_agencies or
|
|
city_lower in ['sno', 'sea', 'tac', 'all'] or
|
|
not city_lower.endswith(('st', 'street', 'ave', 'avenue', 'rd', 'road', 'blvd', 'boulevard',
|
|
'dr', 'drive', 'ct', 'court', 'ln', 'lane', 'way', 'pl', 'place'))):
|
|
# This looks like street + city
|
|
return ("street_city", f"{street_part} {city_part}", None, None)
|
|
|
|
# If no good split found, assume first word is street, rest is city
|
|
street_part = parts[0]
|
|
city_part = ' '.join(parts[1:])
|
|
return ("street_city", f"{street_part} {city_part}", None, None)
|
|
# else: first word doesn't look like a street, treat entire query as city name (fall through)
|
|
|
|
# Check if it's a known county alias (short codes only)
|
|
# County aliases: 'sno' (snohomish), 'sea' (king), 'tac' (pierce), 'all' (all counties)
|
|
query_lower = query.lower()
|
|
if query_lower in ['sno', 'sea', 'tac', 'all']:
|
|
return ("county", query, None, None)
|
|
|
|
# Normalize query to match config key format (spaces -> underscores)
|
|
# Config keys use underscores (e.g., "lake_stevens"), but queries may use spaces
|
|
query_normalized = self._normalize_location_key(query)
|
|
|
|
# Check if it's a configured city name (from config) - check cities first
|
|
# Try both normalized (with underscore) and original (with space) formats
|
|
if query_normalized in self.city_agencies or query_lower in self.city_agencies:
|
|
return ("city", query, None, None)
|
|
|
|
# Check if it's a configured county name (from config)
|
|
# Try both normalized (with underscore) and original (with space) formats
|
|
if query_normalized in self.county_agencies or query_lower in self.county_agencies:
|
|
return ("county", query, None, None)
|
|
|
|
# Default: treat as city name (handles single-word and multi-word city names)
|
|
return ("city", query, None, None)
|
|
|
|
def _match_street_name(self, incidents: List[Dict], street_query: str) -> Tuple[List[Dict], List[Dict]]:
|
|
"""Split incidents into matched and unmatched by street name.
|
|
|
|
Args:
|
|
incidents: List of incidents to filter.
|
|
street_query: Street name to search for.
|
|
|
|
Returns:
|
|
Tuple[List[Dict], List[Dict]]: (matched_incidents, unmatched_incidents).
|
|
"""
|
|
street_lower = street_query.lower().strip()
|
|
matched = []
|
|
unmatched = []
|
|
|
|
for inc in incidents:
|
|
street = inc.get("street", "").lower()
|
|
# Check if street query appears in the incident's street name
|
|
# This handles cases like "178th" matching "178TH AVE" or "NE 178TH ST"
|
|
if street_lower in street:
|
|
matched.append(inc)
|
|
else:
|
|
unmatched.append(inc)
|
|
|
|
return matched, unmatched
|
|
|
|
def _matches_city(self, inc: Dict, city_query: str) -> bool:
|
|
"""Check if incident matches the city name by substring matching on address field.
|
|
|
|
Args:
|
|
inc: Incident dictionary.
|
|
city_query: City name to check.
|
|
|
|
Returns:
|
|
bool: True if matched, False otherwise.
|
|
"""
|
|
city_query_lower = city_query.lower().strip()
|
|
address = inc.get("address", "").lower().strip()
|
|
city = inc.get("city", "").lower().strip()
|
|
|
|
# Check if city name appears in the address field
|
|
return city_query_lower in address
|
|
|
|
def _get_city_match_priority(self, inc: Dict, city_query: str) -> int:
|
|
"""Get priority score for city match (higher = better match).
|
|
|
|
We prioritize matches where the city name appears at the end of the address
|
|
(after a comma), as this is the most reliable indicator. The city field
|
|
can be inaccurate (e.g., showing "SEATTLE" for addresses in King County
|
|
but not actually in Seattle).
|
|
|
|
Args:
|
|
inc: Incident dictionary.
|
|
city_query: City name to match.
|
|
|
|
Returns:
|
|
int: Priority score (2=suffix match, 1=substring match, 0=no match).
|
|
"""
|
|
city_query_lower = city_query.lower().strip()
|
|
address = inc.get("address", "").lower().strip()
|
|
|
|
city_query_clean = city_query_lower.split(',')[0].strip()
|
|
|
|
# Priority 2: City appears at end of address (most reliable - typical format: "STREET, CITY" or "STREET, CITY, STATE")
|
|
# This is the most trustworthy match since it's based on the actual address format
|
|
import re
|
|
end_pattern = r',\s*' + re.escape(city_query_clean) + r'(?:\s*,\s*[A-Z]{2})?$'
|
|
if re.search(end_pattern, address, re.IGNORECASE):
|
|
return 2
|
|
|
|
# Priority 1: City appears anywhere in address (substring match, less reliable)
|
|
# This catches cases where city might be mentioned but not at the end
|
|
if city_query_clean in address:
|
|
return 1
|
|
|
|
# Priority 0: No match
|
|
return 0
|
|
|
|
def _match_city_name(self, incidents: List[Dict], city_query: str) -> Tuple[List[Dict], List[Dict]]:
|
|
"""Split incidents into matched and unmatched by city name.
|
|
|
|
Args:
|
|
incidents: List of incidents to filter.
|
|
city_query: City name to filter by.
|
|
|
|
Returns:
|
|
Tuple[List[Dict], List[Dict]]: (matched_incidents, unmatched_incidents).
|
|
"""
|
|
matched = []
|
|
unmatched = []
|
|
|
|
for inc in incidents:
|
|
if self._matches_city(inc, city_query):
|
|
matched.append(inc)
|
|
else:
|
|
unmatched.append(inc)
|
|
|
|
return matched, unmatched
|
|
|
|
def _sort_by_time(self, incidents: List[Dict]) -> List[Dict]:
|
|
"""Sort incidents by time (most recent first).
|
|
|
|
Args:
|
|
incidents: List of incidents to sort.
|
|
|
|
Returns:
|
|
List[Dict]: Sorted list of incidents.
|
|
"""
|
|
def get_time_key(inc):
|
|
time = inc.get("time")
|
|
if time is None:
|
|
return datetime.min.replace(tzinfo=timezone.utc)
|
|
# Ensure timezone-aware
|
|
if time.tzinfo is None:
|
|
time = time.replace(tzinfo=timezone.utc)
|
|
return time
|
|
|
|
return sorted(incidents, key=get_time_key, reverse=True)
|
|
|
|
def _sort_by_distance_then_time(self, incidents: List[Dict], lat: float, lon: float, max_distance: float = None) -> List[Dict]:
|
|
"""Sort incidents by distance first, then by time (most recent first) within same distance.
|
|
|
|
Args:
|
|
incidents: List of incidents to sort.
|
|
lat: Reference latitude.
|
|
lon: Reference longitude.
|
|
max_distance: Optional max distance in km to filter.
|
|
|
|
Returns:
|
|
List[Dict]: Sorted list of incidents.
|
|
"""
|
|
scored_incidents = []
|
|
for inc in incidents:
|
|
if not self._has_valid_coordinates(inc):
|
|
continue
|
|
|
|
inc_lat = inc.get("latitude", 0)
|
|
inc_lon = inc.get("longitude", 0)
|
|
distance = calculate_distance(lat, lon, inc_lat, inc_lon)
|
|
inc["_distance"] = distance
|
|
|
|
# Filter by max_distance if specified
|
|
if max_distance is None or distance <= max_distance:
|
|
# Get time for secondary sort
|
|
time = inc.get("time")
|
|
if time is None:
|
|
time_key = datetime.min.replace(tzinfo=timezone.utc)
|
|
else:
|
|
if time.tzinfo is None:
|
|
time = time.replace(tzinfo=timezone.utc)
|
|
time_key = time
|
|
inc["_time_key"] = time_key
|
|
scored_incidents.append(inc)
|
|
|
|
# Sort by distance first, then by time (most recent first)
|
|
return sorted(scored_incidents, key=lambda x: (x.get("_distance", float('inf')), -x.get("_time_key", datetime.min).timestamp()))
|
|
|
|
def _has_valid_coordinates(self, inc: Dict) -> bool:
|
|
"""Check if incident has valid coordinates.
|
|
|
|
Args:
|
|
inc: Incident dictionary.
|
|
|
|
Returns:
|
|
bool: True if coordinates are valid and non-zero, False otherwise.
|
|
"""
|
|
inc_lat = inc.get("latitude", 0)
|
|
inc_lon = inc.get("longitude", 0)
|
|
# Valid if both are non-zero and within valid ranges
|
|
return (inc_lat != 0.0 and inc_lon != 0.0 and
|
|
-90 <= inc_lat <= 90 and -180 <= inc_lon <= 180)
|
|
|
|
def _sort_by_distance(self, incidents: List[Dict], lat: float, lon: float, max_distance: float = None) -> List[Dict]:
|
|
"""Sort incidents by distance from given coordinates.
|
|
|
|
Args:
|
|
incidents: List of incident dicts.
|
|
lat: Target latitude.
|
|
lon: Target longitude.
|
|
max_distance: Optional maximum distance in km (incidents beyond this are excluded).
|
|
|
|
Returns:
|
|
List[Dict]: Sorted list of incidents (closest first). Only includes incidents with valid coordinates.
|
|
"""
|
|
scored_incidents = []
|
|
for inc in incidents:
|
|
if not self._has_valid_coordinates(inc):
|
|
# Skip incidents without valid coordinates - they'll be handled separately
|
|
continue
|
|
|
|
inc_lat = inc.get("latitude", 0)
|
|
inc_lon = inc.get("longitude", 0)
|
|
distance = calculate_distance(lat, lon, inc_lat, inc_lon)
|
|
inc["_distance"] = distance
|
|
|
|
# Filter by max_distance if specified
|
|
if max_distance is None or distance <= max_distance:
|
|
scored_incidents.append(inc)
|
|
|
|
# Sort by distance (closest first)
|
|
return sorted(scored_incidents, key=lambda x: x.get("_distance", float('inf')))
|
|
|
|
def _format_incident_compact(self, inc: Dict) -> str:
|
|
"""Format a single incident in compact format.
|
|
|
|
Args:
|
|
inc: Incident dictionary.
|
|
|
|
Returns:
|
|
str: Formatted incident string for display.
|
|
"""
|
|
# Get first unit with status icon
|
|
unit_str = ""
|
|
if inc.get("units"):
|
|
u = inc["units"][0]
|
|
status_icon = {"DP": "⏳", "ER": "🚗", "OS": "📍", "TR": "🏥"}.get(u["status_code"], "")
|
|
unit_str = f" [{u['id']}{status_icon}]"
|
|
|
|
# Shorten city name
|
|
city = inc.get("city", "")
|
|
if city:
|
|
city = city.replace(", WA", "").replace(" COUNTY", " CO")
|
|
city_part = f", {city}"
|
|
else:
|
|
city_part = ""
|
|
|
|
time_ago = inc.get("time_ago", "")
|
|
time_part = f" ({time_ago})" if time_ago else ""
|
|
|
|
return f"{inc['type']}: {inc['street']}{city_part}{time_part}{unit_str}"
|
|
|
|
def _format_response(self, incidents: List[Dict], max_length: int = 130) -> str:
|
|
"""Format incidents into a single message, limiting to max_length.
|
|
|
|
Args:
|
|
incidents: List of incidents to format.
|
|
max_length: Maximum length of the output string (default 130 for LoRa).
|
|
|
|
Returns:
|
|
str: Formatted response string.
|
|
"""
|
|
if not incidents:
|
|
return "🚨 No active incidents"
|
|
|
|
lines = ["🚨"]
|
|
# Start with emoji (2 chars) + newline (1 char) = 3 chars
|
|
current_length = 3
|
|
|
|
remaining = len(incidents)
|
|
|
|
for i, inc in enumerate(incidents):
|
|
line = self._format_incident_compact(inc)
|
|
# Length includes the line content + newline character
|
|
line_length = len(line) + 1
|
|
|
|
# Check if we can fit this line at all
|
|
if current_length + line_length > max_length:
|
|
# Can't fit this line
|
|
if len(lines) > 1: # At least one incident shown
|
|
lines.append(f"({remaining} more)")
|
|
break
|
|
|
|
# Check if this is the last incident
|
|
is_last = (remaining == 1)
|
|
|
|
if is_last:
|
|
# Last incident, no "(X more)" needed, add it
|
|
lines.append(line)
|
|
current_length += line_length
|
|
remaining -= 1
|
|
else:
|
|
# Not the last incident, check if we can fit line + "(X more)"
|
|
more_text = f" ({remaining - 1} more)"
|
|
if current_length + line_length + len(more_text) > max_length:
|
|
# Can't fit both line and "(X more)", show count instead
|
|
if len(lines) > 1: # At least one incident shown
|
|
lines.append(f"({remaining} more)")
|
|
break
|
|
else:
|
|
# Can fit both line and "(X more)", add the line
|
|
lines.append(line)
|
|
current_length += line_length
|
|
remaining -= 1
|
|
|
|
# Build final message
|
|
final_message = "\n".join(lines)
|
|
|
|
# Safety check: ensure we don't exceed max_length (shouldn't happen, but be safe)
|
|
if len(final_message) > max_length:
|
|
# Find the last complete line before max_length
|
|
# Look for the last newline that would keep us under the limit
|
|
last_newline = final_message.rfind('\n', 0, max_length - 15) # Reserve 15 chars for "(X more)"
|
|
if last_newline > 0 and len(lines) > 1:
|
|
# Truncate at the last complete line
|
|
final_message = final_message[:last_newline]
|
|
# Add count if there are remaining incidents
|
|
if remaining > 0:
|
|
final_message += f"\n({remaining} more)"
|
|
else:
|
|
# Fallback: just truncate (shouldn't happen with proper logic above)
|
|
final_message = final_message[:max_length].rstrip()
|
|
|
|
return final_message
|
|
|
|
async def _send_all_response(self, message: MeshMessage, incidents: List[Dict]) -> None:
|
|
"""Send up to 10 incidents in multiple messages, grouping efficiently.
|
|
|
|
Args:
|
|
message: The message to respond to.
|
|
incidents: List of incidents to send.
|
|
"""
|
|
import asyncio
|
|
|
|
if not incidents:
|
|
await self.send_response(message, "🚨 No active incidents")
|
|
return
|
|
|
|
# Build messages efficiently, grouping incidents to fit within 130 chars
|
|
messages = []
|
|
header = f"🚨 {len(incidents)} incident(s):"
|
|
|
|
# Start first message with header
|
|
current_lines = [header]
|
|
current_length = len(header)
|
|
|
|
for inc in incidents:
|
|
incident_text = self._format_incident_compact(inc)
|
|
incident_length = len(incident_text)
|
|
|
|
# Calculate what the message would look like with this incident added
|
|
# Need to account for newline character between lines
|
|
test_length = current_length + 1 + incident_length # +1 for newline
|
|
|
|
# Check if this incident fits in the current message
|
|
if test_length <= 130:
|
|
# It fits, add it
|
|
current_lines.append(incident_text)
|
|
current_length = test_length
|
|
else:
|
|
# Doesn't fit - finalize current message and start new one
|
|
if len(current_lines) > 1: # Has at least header + one incident
|
|
messages.append("\n".join(current_lines))
|
|
else:
|
|
# Only header, must add at least one incident even if it exceeds limit
|
|
current_lines.append(incident_text)
|
|
messages.append("\n".join(current_lines))
|
|
current_lines = []
|
|
current_length = 0
|
|
continue
|
|
|
|
# Start new message with this incident (no header for subsequent messages)
|
|
current_lines = [incident_text]
|
|
current_length = incident_length
|
|
|
|
# Add the last message if it has content
|
|
if current_lines:
|
|
# If we only have header, add first incident
|
|
if len(current_lines) == 1 and len(incidents) > 0:
|
|
current_lines.append(self._format_incident_compact(incidents[0]))
|
|
messages.append("\n".join(current_lines))
|
|
|
|
# Send all messages with delays between them
|
|
for i, msg in enumerate(messages):
|
|
await self.send_response(message, msg)
|
|
# Wait between messages (except after the last one)
|
|
if i < len(messages) - 1:
|
|
await asyncio.sleep(2.0)
|
|
|
|
async def execute(self, message: MeshMessage) -> bool:
|
|
"""Execute the alert command.
|
|
|
|
Parses query, fetches incidents, filters/sorts, and sends response.
|
|
|
|
Args:
|
|
message: The message triggering the command.
|
|
|
|
Returns:
|
|
bool: True if executed successfully, False otherwise.
|
|
"""
|
|
content = message.content.strip()
|
|
|
|
# Parse command
|
|
parts = content.split(None, 1)
|
|
if len(parts) < 2:
|
|
# No query provided, use default location or show help
|
|
await self.send_response(message, "Usage: alert <city|zipcode|street city|lat,lon|county> [all]")
|
|
return True
|
|
|
|
query = parts[1].strip()
|
|
|
|
# Check for "all" flag at the end
|
|
show_all = False
|
|
if query.lower().endswith(' all'):
|
|
show_all = True
|
|
query = query[:-4].strip() # Remove " all" from the end
|
|
|
|
try:
|
|
# Parse the query
|
|
query_type, location, lat, lon = self._parse_query(query)
|
|
self.logger.debug(f"Parsed query '{query}' as type: {query_type}, location: {location}")
|
|
|
|
# Get agency IDs based on query type
|
|
if query_type == "county":
|
|
agency_ids = self._get_agency_ids(location, "county")
|
|
elif query_type == "city":
|
|
# For city queries, try to get city-specific agencies, fall back to all
|
|
agency_ids = self._get_agency_ids(location, "city")
|
|
if agency_ids is None:
|
|
# No city-specific agencies configured, use all agencies
|
|
agency_ids = self._get_agency_ids()
|
|
else:
|
|
# For other queries (zipcode, coordinates, street_city), use all agencies
|
|
agency_ids = self._get_agency_ids() # Default to all
|
|
|
|
# Fetch incidents
|
|
incidents = self._fetch_incidents(agency_ids)
|
|
|
|
if not incidents:
|
|
await self.send_response(message, "🚨 No active incidents")
|
|
return True
|
|
|
|
# Process based on query type
|
|
if query_type == "coordinates":
|
|
# Sort by distance with configurable max distance, then by time
|
|
incidents = self._sort_by_distance_then_time(incidents, lat, lon, max_distance=self.max_distance_km)
|
|
elif query_type == "zipcode":
|
|
# Geocode zipcode and get city name
|
|
zip_lat, zip_lon = geocode_zipcode_sync(self.bot, location)
|
|
zip_city = None
|
|
|
|
if zip_lat and zip_lon:
|
|
# Get city name from zipcode via reverse geocoding
|
|
try:
|
|
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{zip_lat}, {zip_lon}", timeout=10)
|
|
if reverse_location and reverse_location.raw:
|
|
address = reverse_location.raw.get('address', {})
|
|
zip_city = (address.get('city') or
|
|
address.get('town') or
|
|
address.get('village') or
|
|
address.get('hamlet') or
|
|
address.get('municipality') or
|
|
address.get('suburb') or '')
|
|
if zip_city:
|
|
zip_city = zip_city.lower().strip()
|
|
self.logger.debug(f"Zipcode {location} maps to city: {zip_city}")
|
|
except Exception as e:
|
|
self.logger.debug(f"Error getting city from zipcode: {e}")
|
|
|
|
# Split incidents by coordinate validity
|
|
with_coords = [inc for inc in incidents if self._has_valid_coordinates(inc)]
|
|
without_coords = [inc for inc in incidents if not self._has_valid_coordinates(inc)]
|
|
|
|
# Prioritize incidents that match the zipcode's city
|
|
if zip_city:
|
|
# Filter by city name match, then sort by distance and time
|
|
matched_coords, _ = self._match_city_name(with_coords, zip_city)
|
|
matched_no_coords, _ = self._match_city_name(without_coords, zip_city)
|
|
|
|
# Sort matched incidents by distance (for those with coords), then time
|
|
matched_coords = self._sort_by_distance_then_time(matched_coords, zip_lat, zip_lon, max_distance=None)
|
|
matched_no_coords = self._sort_by_time(matched_no_coords)
|
|
|
|
# If we have matches, show those. Otherwise, show nearby incidents within max distance
|
|
if len(matched_coords) > 0 or len(matched_no_coords) > 0:
|
|
incidents = matched_coords + matched_no_coords
|
|
else:
|
|
# No city matches, show nearby incidents within max distance
|
|
nearby_coords = self._sort_by_distance_then_time(with_coords, zip_lat, zip_lon, max_distance=self.max_distance_km)
|
|
nearby_no_coords = self._sort_by_time(without_coords)
|
|
incidents = nearby_coords + nearby_no_coords
|
|
else:
|
|
# No city name available, just sort by distance
|
|
incidents = self._sort_by_distance_then_time(incidents, zip_lat, zip_lon, max_distance=self.max_distance_km)
|
|
elif query_type == "street_city":
|
|
# Extract street and city
|
|
parts = location.split(None, 1)
|
|
if len(parts) == 2:
|
|
street_query, city_query = parts
|
|
# Geocode city first
|
|
result = geocode_city_sync(self.bot, city_query, include_address_info=False)
|
|
city_lat, city_lon = None, None
|
|
if len(result) >= 2:
|
|
city_lat, city_lon = result[0], result[1]
|
|
|
|
# Split incidents by coordinate validity
|
|
with_coords = [inc for inc in incidents if self._has_valid_coordinates(inc)]
|
|
without_coords = [inc for inc in incidents if not self._has_valid_coordinates(inc)]
|
|
|
|
# Process incidents with coordinates
|
|
if city_lat and city_lon:
|
|
# Sort by distance (closest first) with max distance filter
|
|
with_coords = self._sort_by_distance(with_coords, city_lat, city_lon, max_distance=self.max_distance_km)
|
|
# Prioritize street-matched incidents by re-sorting
|
|
matched_street_coords, unmatched_street_coords = self._match_street_name(with_coords, street_query)
|
|
# Re-sort both groups by distance then time to maintain proximity ordering
|
|
matched_street_coords = self._sort_by_distance_then_time(matched_street_coords, city_lat, city_lon, max_distance=self.max_distance_km)
|
|
unmatched_street_coords = self._sort_by_distance_then_time(unmatched_street_coords, city_lat, city_lon, max_distance=self.max_distance_km)
|
|
with_coords = matched_street_coords + unmatched_street_coords
|
|
else:
|
|
# Geocoding failed - fall back to address matching for incidents with coordinates
|
|
matched_street_coords, unmatched_street_coords = self._match_street_name(with_coords, street_query)
|
|
matched_city_coords, _ = self._match_city_name(matched_street_coords + unmatched_street_coords, city_query)
|
|
with_coords = matched_city_coords
|
|
# Sort by time
|
|
with_coords = self._sort_by_time(with_coords)
|
|
|
|
# Process incidents without coordinates: match by street name and city name
|
|
matched_street, unmatched_street = self._match_street_name(without_coords, street_query)
|
|
# Also match by city name in address for those without coordinates
|
|
matched_city, _ = self._match_city_name(matched_street + unmatched_street, city_query)
|
|
without_coords = matched_city
|
|
# Sort by time
|
|
without_coords = self._sort_by_time(without_coords)
|
|
|
|
# Combine: incidents with coordinates (sorted by distance or matched by address) first, then address-matched ones without coordinates
|
|
incidents = with_coords + without_coords
|
|
elif query_type == "city":
|
|
# Filter incidents by city name match - ONLY show matches where city appears at end of address
|
|
# This is the most reliable indicator and avoids false positives
|
|
matched, _ = self._match_city_name(incidents, location)
|
|
|
|
# Only keep incidents where city name appears at end of address (Priority 2)
|
|
# This ensures we only show incidents that are actually in the queried city
|
|
high_priority = []
|
|
for inc in matched:
|
|
priority = self._get_city_match_priority(inc, location)
|
|
if priority >= 2: # Only city at end of address
|
|
high_priority.append(inc)
|
|
else:
|
|
# Log why an incident was excluded (for debugging)
|
|
self.logger.debug(f"Excluding incident: {inc.get('address', 'N/A')[:60]} (priority: {priority})")
|
|
|
|
# Sort by time (most recent first)
|
|
incidents = self._sort_by_time(high_priority)
|
|
self.logger.debug(f"City query '{location}': {len(incidents)} matches (city at end of address only), {len(matched) - len(high_priority)} excluded")
|
|
elif query_type == "county":
|
|
# For county queries, return all incidents from that county (no city filtering)
|
|
# Sort by time (most recent first)
|
|
incidents = self._sort_by_time(incidents)
|
|
self.logger.debug(f"County query '{location}': returning {len(incidents)} incidents (no city filtering)")
|
|
else:
|
|
# Unknown query type - this shouldn't happen, but log it
|
|
self.logger.warning(f"Unknown query type: {query_type} for query: {query}")
|
|
# Default: sort by time
|
|
incidents = self._sort_by_time(incidents)
|
|
|
|
# Limit to 10 incidents if "all" mode is enabled
|
|
if show_all:
|
|
incidents = incidents[:10]
|
|
await self._send_all_response(message, incidents)
|
|
else:
|
|
# Format and send response (compact mode)
|
|
response = self._format_response(incidents)
|
|
await self.send_response(message, response)
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error in alert command: {e}")
|
|
import traceback
|
|
self.logger.error(traceback.format_exc())
|
|
await self.send_response(message, f"Error fetching alerts: {str(e)}")
|
|
return True
|
|
|
|
|