mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-03-30 12:05:38 +00:00
- Updated the `generate_html` function to include detailed command usage information, including syntax, examples, and parameters for better user guidance. - Added CSS styles for improved presentation of command usage and parameters in the generated website documentation. - Enhanced command classes with structured documentation fields, allowing for consistent and informative command descriptions across the platform.
1086 lines
48 KiB
Python
1086 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')
|
|
|
|
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.
|
|
"""
|
|
# Check if alert command is enabled
|
|
alert_enabled = self.get_config_value('Alert_Command', 'alert_enabled', fallback=True, value_type='bool')
|
|
if not 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
|
|
|
|
|