mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-27 21:10:13 +00:00
Add alert command configuration to config.ini.example and README. Update install-service.sh to support macOS and Linux service installations with improved user prompts and error handling. Enhance installation script with detailed steps for setting up the bot as a system service, including virtual environment creation and dependency installation.
This commit is contained in:
@@ -99,7 +99,7 @@ startup_advert = flood # Send advert on startup
|
||||
# Format: keyword = response_template
|
||||
# Variables: {sender}, {connection_info}, {snr}, {timestamp}, {path}
|
||||
test = "Message received from {sender} | {connection_info}"
|
||||
help = "Bot Help: test, ping, help, hello, cmd, wx, aqi, sun, moon, solar, hfcond, satpass, dice, roll, joke, dadjoke, sports, channels, path, prefix, repeater, stats"
|
||||
help = "Bot Help: test, ping, help, hello, cmd, wx, aqi, sun, moon, solar, hfcond, satpass, dice, roll, joke, dadjoke, sports, channels, path, prefix, repeater, stats, alert"
|
||||
```
|
||||
|
||||
### Channels
|
||||
@@ -117,6 +117,16 @@ n2yo_api_key = # Satellite pass data
|
||||
airnow_api_key = # Air quality data
|
||||
```
|
||||
|
||||
### Alert Command
|
||||
```ini
|
||||
[Alert_Command]
|
||||
alert_enabled = true # Enable/disable alert command
|
||||
max_incident_age_hours = 24 # Maximum age for incidents (hours)
|
||||
max_distance_km = 20.0 # Maximum distance for proximity queries (km)
|
||||
agency.city.<city_name> = <agency_ids> # City-specific agency IDs (e.g., agency.city.seattle = 17D20,17M15)
|
||||
agency.county.<county_name> = <agency_ids> # County-specific agency IDs (aggregates all city agencies)
|
||||
```
|
||||
|
||||
### Logging
|
||||
```ini
|
||||
[Logging]
|
||||
@@ -159,6 +169,9 @@ The bot responds to these commands:
|
||||
- `satpass <NORAD> visual` - Visual passes only (must be visually observable)
|
||||
- `satpass <shortcut>` - Use shortcuts like `iss`, `hst`, `hubble`, `goes18`, `tiangong`
|
||||
|
||||
**Emergency Commands:**
|
||||
- `alert <city|zipcode|street city|lat,lon|county> [all]` - Get active emergency incidents (e.g., `alert seattle`, `alert 98101`, `alert seattle all`)
|
||||
|
||||
**Gaming Commands:**
|
||||
- `dice` - Roll dice (d6 by default, or specify like `dice d20`, `dice 2d6`)
|
||||
- `roll` - Roll random number (1-100 by default, or specify like `roll 50`)
|
||||
@@ -214,7 +227,7 @@ Example:
|
||||
[Keywords]
|
||||
test = "Message received from {sender} | {connection_info}"
|
||||
ping = "Pong!"
|
||||
help = "Bot Help: test, ping, help, hello, cmd, wx, gwx, aqi, sun, moon, solar, solarforecast, hfcond, satpass, dice, roll, joke, dadjoke, sports, channels, path, prefix, repeater, stats, multitest, webviewer"
|
||||
help = "Bot Help: test, ping, help, hello, cmd, wx, gwx, aqi, sun, moon, solar, solarforecast, hfcond, satpass, dice, roll, joke, dadjoke, sports, channels, path, prefix, repeater, stats, multitest, alert, webviewer"
|
||||
```
|
||||
|
||||
## Hardware Setup
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.meshcore.bot</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/meshcore-bot/venv/bin/python</string>
|
||||
<string>/usr/local/meshcore-bot/meshcore_bot.py</string>
|
||||
</array>
|
||||
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/usr/local/meshcore-bot</string>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/usr/local/var/log/meshcore-bot/meshcore-bot.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/usr/local/var/log/meshcore-bot/meshcore-bot.error.log</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PYTHONPATH</key>
|
||||
<string>/usr/local/meshcore-bot</string>
|
||||
<key>PYTHONUNBUFFERED</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
|
||||
<key>ProcessType</key>
|
||||
<string>Background</string>
|
||||
|
||||
<key>Nice</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -599,6 +599,28 @@ enable_p_shortcut = false
|
||||
# false: Hacker command is disabled
|
||||
hacker_enabled = false
|
||||
|
||||
[Alert_Command]
|
||||
# Enable or disable the alert command
|
||||
# true: Alert command is available
|
||||
# false: Alert command is disabled
|
||||
alert_enabled = false
|
||||
|
||||
# PulsePoint agency IDs by county/region
|
||||
# Format: agency.<county_name> = comma-separated agency IDs
|
||||
# Get agency IDs from: https://web.pulsepoint.org/ or PulsePoint searchagencies API
|
||||
# You can use any naming convention that makes sense for your region
|
||||
# Examples: agency.county1, agency.city1, agency.region1, etc.
|
||||
|
||||
# Example: County agencies
|
||||
# agency.county1 =
|
||||
|
||||
# Example: City agencies (can have multiple entries for the same region)
|
||||
# agency.city1 =
|
||||
# agency.city1_alias =
|
||||
|
||||
# Example: Combined region (all counties/agencies)
|
||||
# agency.region_all =
|
||||
|
||||
[Web_Viewer]
|
||||
# Enable or disable the web data viewer
|
||||
# true: web viewer is available for viewing bot data
|
||||
|
||||
+392
-76
@@ -1,6 +1,20 @@
|
||||
#!/bin/bash
|
||||
# MeshCore Bot Service Installation Script
|
||||
# This script installs the MeshCore Bot as a systemd service
|
||||
# This script installs the MeshCore Bot as a system service
|
||||
# Supports both Linux (systemd) and macOS (launchd)
|
||||
#
|
||||
# This script will:
|
||||
# 1. Create a dedicated system user for the bot (Linux only)
|
||||
# 2. Copy bot files to installation directory
|
||||
# 3. Set up proper file permissions
|
||||
# 4. Install and enable the service (systemd or launchd)
|
||||
# 5. Create a Python virtual environment with dependencies
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Linux system with systemd OR macOS
|
||||
# - Python 3.7+ installed
|
||||
# - sudo access (script will prompt if needed)
|
||||
# - Run from the meshcore-bot directory
|
||||
|
||||
set -e
|
||||
|
||||
@@ -9,119 +23,421 @@ RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
SERVICE_NAME="meshcore-bot"
|
||||
SERVICE_USER="meshcore"
|
||||
SERVICE_GROUP="meshcore"
|
||||
INSTALL_DIR="/opt/meshcore-bot"
|
||||
LOG_DIR="/var/log/meshcore-bot"
|
||||
SERVICE_FILE="meshcore-bot.service"
|
||||
# Detect operating system
|
||||
OS="$(uname -s)"
|
||||
IS_MACOS=false
|
||||
IS_LINUX=false
|
||||
|
||||
echo -e "${BLUE}MeshCore Bot Service Installer${NC}"
|
||||
echo "=================================="
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo -e "${RED}This script must be run as root (use sudo)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if systemd is available
|
||||
if ! command -v systemctl &> /dev/null; then
|
||||
echo -e "${RED}systemd is not available on this system${NC}"
|
||||
if [[ "$OS" == "Darwin" ]]; then
|
||||
IS_MACOS=true
|
||||
SERVICE_TYPE="launchd"
|
||||
elif [[ "$OS" == "Linux" ]]; then
|
||||
IS_LINUX=true
|
||||
SERVICE_TYPE="systemd"
|
||||
else
|
||||
echo "Error: Unsupported operating system: $OS"
|
||||
echo "This script supports Linux (systemd) and macOS (launchd)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Step 1: Creating service user and group${NC}"
|
||||
# Create service user and group
|
||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
||||
useradd --system --no-create-home --shell /bin/false "$SERVICE_USER"
|
||||
echo -e "${GREEN}Created user: $SERVICE_USER${NC}"
|
||||
# Configuration - OS-specific paths
|
||||
SERVICE_NAME="meshcore-bot"
|
||||
PLIST_NAME="com.meshcore.bot"
|
||||
|
||||
if [[ "$IS_MACOS" == true ]]; then
|
||||
SERVICE_USER="$(whoami)" # macOS: use current user or _meshcore
|
||||
SERVICE_GROUP="staff"
|
||||
INSTALL_DIR="/usr/local/meshcore-bot"
|
||||
LOG_DIR="/usr/local/var/log/meshcore-bot"
|
||||
SERVICE_FILE="com.meshcore.bot.plist"
|
||||
LAUNCHD_DIR="/Library/LaunchDaemons"
|
||||
else
|
||||
echo -e "${YELLOW}User $SERVICE_USER already exists${NC}"
|
||||
SERVICE_USER="meshcore"
|
||||
SERVICE_GROUP="meshcore"
|
||||
INSTALL_DIR="/opt/meshcore-bot"
|
||||
LOG_DIR="/var/log/meshcore-bot"
|
||||
SERVICE_FILE="meshcore-bot.service"
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Step 2: Creating directories${NC}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Function to print section headers
|
||||
print_section() {
|
||||
echo ""
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE}$1${NC}"
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
}
|
||||
|
||||
# Function to print info messages
|
||||
print_info() {
|
||||
echo -e "${CYAN}ℹ${NC} $1"
|
||||
}
|
||||
|
||||
# Function to print success messages
|
||||
print_success() {
|
||||
echo -e "${GREEN}✓${NC} $1"
|
||||
}
|
||||
|
||||
# Function to print warning messages
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠${NC} $1"
|
||||
}
|
||||
|
||||
# Function to print error messages
|
||||
print_error() {
|
||||
echo -e "${RED}✗${NC} $1"
|
||||
}
|
||||
|
||||
print_section "MeshCore Bot Service Installer"
|
||||
echo ""
|
||||
if [[ "$IS_MACOS" == true ]]; then
|
||||
print_info "Detected macOS - will install as launchd service"
|
||||
print_info "The bot will start automatically on boot using launchd"
|
||||
else
|
||||
print_info "Detected Linux - will install as systemd service"
|
||||
print_info "The bot will run as a dedicated user and start automatically on boot"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check if script has execute permissions
|
||||
if [ ! -x "$0" ]; then
|
||||
print_warning "Script does not have execute permissions. Attempting to set them..."
|
||||
chmod +x "$0" 2>/dev/null || {
|
||||
print_error "Could not set execute permissions. Please run: chmod +x install-service.sh"
|
||||
exit 1
|
||||
}
|
||||
print_success "Execute permissions set"
|
||||
fi
|
||||
|
||||
# Capture original user before sudo (for macOS)
|
||||
ORIGINAL_USER="${SUDO_USER:-$USER}"
|
||||
|
||||
# Check if running as root, if not re-execute with sudo
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
print_warning "This script requires root privileges to install system services"
|
||||
print_info "Re-executing with sudo..."
|
||||
echo ""
|
||||
exec sudo "$0" "$@"
|
||||
fi
|
||||
|
||||
# Verify we're in the right directory
|
||||
if [ ! -f "meshcore_bot.py" ]; then
|
||||
print_error "This script must be run from the meshcore-bot directory"
|
||||
print_error "Expected file not found: meshcore_bot.py"
|
||||
print_info "Please cd to the meshcore-bot directory and run this script again"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for service file
|
||||
if [ ! -f "$SERVICE_FILE" ]; then
|
||||
print_error "Service file not found: $SERVICE_FILE"
|
||||
if [[ "$IS_MACOS" == true ]]; then
|
||||
print_info "Expected: com.meshcore.bot.plist"
|
||||
else
|
||||
print_info "Expected: meshcore-bot.service"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# OS-specific service manager checks
|
||||
if [[ "$IS_MACOS" == true ]]; then
|
||||
if ! command -v launchctl &> /dev/null; then
|
||||
print_error "launchctl is not available on this system"
|
||||
print_error "This script requires macOS with launchd"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if ! command -v systemctl &> /dev/null; then
|
||||
print_error "systemd is not available on this system"
|
||||
print_error "This script requires a Linux system with systemd"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if Python 3 is available
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
print_error "Python 3 is not installed or not in PATH"
|
||||
print_error "Please install Python 3.7 or higher before running this script"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_section "Step 1: Setting Up Service User"
|
||||
if [[ "$IS_MACOS" == true ]]; then
|
||||
# Use original user if available, otherwise root
|
||||
if [[ -n "$ORIGINAL_USER" && "$ORIGINAL_USER" != "root" ]]; then
|
||||
SERVICE_USER="$ORIGINAL_USER"
|
||||
SERVICE_GROUP="$(id -gn "$ORIGINAL_USER" 2>/dev/null || echo "staff")"
|
||||
else
|
||||
SERVICE_USER="root"
|
||||
SERVICE_GROUP="wheel"
|
||||
fi
|
||||
print_info "macOS: Service will run as user '$SERVICE_USER'"
|
||||
print_info "On macOS, launchd services run as the specified user"
|
||||
print_success "Using user: $SERVICE_USER"
|
||||
else
|
||||
print_info "Creating a dedicated system user '$SERVICE_USER' for security"
|
||||
print_info "This user will run the bot service with minimal privileges"
|
||||
# Create service user and group
|
||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
||||
useradd --system --no-create-home --shell /bin/false "$SERVICE_USER"
|
||||
print_success "Created system user: $SERVICE_USER"
|
||||
else
|
||||
print_warning "User $SERVICE_USER already exists (skipping creation)"
|
||||
fi
|
||||
fi
|
||||
|
||||
print_section "Step 2: Creating Installation Directories"
|
||||
print_info "Creating directory structure for bot installation"
|
||||
# Create installation directory
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
print_warning "Installation directory $INSTALL_DIR already exists"
|
||||
print_info "Existing files will be backed up and replaced"
|
||||
BACKUP_DIR="${INSTALL_DIR}.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
mv "$INSTALL_DIR" "$BACKUP_DIR" 2>/dev/null || {
|
||||
print_error "Could not backup existing installation. Please remove $INSTALL_DIR manually"
|
||||
exit 1
|
||||
}
|
||||
print_success "Backed up existing installation to $BACKUP_DIR"
|
||||
fi
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
echo -e "${GREEN}Created directory: $INSTALL_DIR${NC}"
|
||||
print_success "Created installation directory: $INSTALL_DIR"
|
||||
|
||||
# Create log directory
|
||||
mkdir -p "$LOG_DIR"
|
||||
echo -e "${GREEN}Created directory: $LOG_DIR${NC}"
|
||||
print_success "Created log directory: $LOG_DIR"
|
||||
|
||||
echo -e "${YELLOW}Step 3: Copying bot files${NC}"
|
||||
print_section "Step 3: Copying Bot Files"
|
||||
print_info "Copying all bot files to $INSTALL_DIR"
|
||||
print_info "This includes Python scripts, modules, configuration templates, and documentation"
|
||||
# Copy bot files to installation directory
|
||||
cp -r . "$INSTALL_DIR/"
|
||||
echo -e "${GREEN}Copied bot files to $INSTALL_DIR${NC}"
|
||||
cp -r . "$INSTALL_DIR/" 2>/dev/null || {
|
||||
print_error "Failed to copy files. Check permissions and disk space"
|
||||
exit 1
|
||||
}
|
||||
print_success "Copied bot files to $INSTALL_DIR"
|
||||
|
||||
echo -e "${YELLOW}Step 4: Setting permissions${NC}"
|
||||
print_section "Step 4: Setting File Permissions"
|
||||
print_info "Configuring file ownership and permissions for security"
|
||||
print_info "The service user will own all files, with appropriate read/write permissions"
|
||||
# Set ownership
|
||||
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR"
|
||||
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$LOG_DIR"
|
||||
print_success "Set ownership to $SERVICE_USER:$SERVICE_GROUP"
|
||||
|
||||
# Set permissions
|
||||
chmod 755 "$INSTALL_DIR"
|
||||
chmod 644 "$INSTALL_DIR"/*.py
|
||||
chmod 644 "$INSTALL_DIR"/*.ini
|
||||
chmod 644 "$INSTALL_DIR"/*.txt
|
||||
chmod 755 "$INSTALL_DIR"/modules
|
||||
chmod 644 "$INSTALL_DIR"/modules/*.py
|
||||
chmod 755 "$INSTALL_DIR"/modules/commands
|
||||
chmod 644 "$INSTALL_DIR"/modules/commands/*.py
|
||||
find "$INSTALL_DIR" -type f -name "*.py" -exec chmod 644 {} \; 2>/dev/null || true
|
||||
find "$INSTALL_DIR" -type f -name "*.ini" -exec chmod 644 {} \; 2>/dev/null || true
|
||||
find "$INSTALL_DIR" -type f -name "*.txt" -exec chmod 644 {} \; 2>/dev/null || true
|
||||
find "$INSTALL_DIR" -type f -name "*.json" -exec chmod 644 {} \; 2>/dev/null || true
|
||||
find "$INSTALL_DIR" -type d -exec chmod 755 {} \; 2>/dev/null || true
|
||||
|
||||
# Make main script executable
|
||||
chmod 755 "$INSTALL_DIR/meshcore_bot.py"
|
||||
print_success "Configured file permissions"
|
||||
|
||||
echo -e "${GREEN}Set permissions for bot files${NC}"
|
||||
print_section "Step 5: Installing Service"
|
||||
if [[ "$IS_MACOS" == true ]]; then
|
||||
print_info "Installing launchd plist file to enable automatic startup"
|
||||
print_info "The service will be configured to start on boot and restart on failure"
|
||||
# Create LaunchDaemons directory if it doesn't exist
|
||||
mkdir -p "$LAUNCHD_DIR"
|
||||
|
||||
# Update plist with actual installation paths and copy to LaunchDaemons
|
||||
print_info "Updating plist file with installation paths"
|
||||
# Use a more portable approach for path substitution
|
||||
if command -v python3 &> /dev/null; then
|
||||
python3 -c "
|
||||
import sys
|
||||
import re
|
||||
with open('$SERVICE_FILE', 'r') as f:
|
||||
content = f.read()
|
||||
content = content.replace('/usr/local/meshcore-bot', '$INSTALL_DIR')
|
||||
content = content.replace('/usr/local/var/log/meshcore-bot', '$LOG_DIR')
|
||||
with open('$LAUNCHD_DIR/$SERVICE_FILE', 'w') as f:
|
||||
f.write(content)
|
||||
"
|
||||
else
|
||||
# Fallback to sed (works on both macOS and Linux)
|
||||
sed "s|/usr/local/meshcore-bot|$INSTALL_DIR|g; s|/usr/local/var/log/meshcore-bot|$LOG_DIR|g" "$SERVICE_FILE" > "$LAUNCHD_DIR/$SERVICE_FILE"
|
||||
fi
|
||||
print_success "Copied and configured plist file to $LAUNCHD_DIR/"
|
||||
|
||||
# Set ownership
|
||||
chown root:wheel "$LAUNCHD_DIR/$SERVICE_FILE"
|
||||
chmod 644 "$LAUNCHD_DIR/$SERVICE_FILE"
|
||||
print_success "Set plist permissions"
|
||||
|
||||
print_section "Step 6: Loading Service"
|
||||
print_info "Loading service into launchd"
|
||||
# Unload if already loaded
|
||||
launchctl list "$PLIST_NAME" &>/dev/null && launchctl unload "$LAUNCHD_DIR/$SERVICE_FILE" 2>/dev/null || true
|
||||
# Load the service
|
||||
launchctl load "$LAUNCHD_DIR/$SERVICE_FILE" 2>/dev/null || {
|
||||
print_error "Failed to load service. Check plist syntax and permissions."
|
||||
exit 1
|
||||
}
|
||||
print_success "Service '$PLIST_NAME' loaded into launchd"
|
||||
print_info "Note: The service is loaded but not started yet. You'll start it after configuration."
|
||||
else
|
||||
print_info "Installing systemd service file to enable automatic startup"
|
||||
print_info "The service will be configured to start on boot and restart on failure"
|
||||
# Copy service file to systemd directory
|
||||
cp "$SERVICE_FILE" "$SYSTEMD_DIR/"
|
||||
print_success "Copied service file to $SYSTEMD_DIR/"
|
||||
|
||||
# Reload systemd
|
||||
print_info "Reloading systemd to recognize the new service"
|
||||
systemctl daemon-reload
|
||||
print_success "Systemd configuration reloaded"
|
||||
|
||||
print_section "Step 6: Enabling Service"
|
||||
print_info "Enabling service to start automatically on system boot"
|
||||
# Enable service to start on boot
|
||||
systemctl enable "$SERVICE_NAME" >/dev/null 2>&1
|
||||
print_success "Service '$SERVICE_NAME' enabled for automatic startup"
|
||||
print_info "Note: The service is enabled but not started yet. You'll start it after configuration."
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Step 5: Installing systemd service${NC}"
|
||||
# Copy service file to systemd directory
|
||||
cp "$SERVICE_FILE" "/etc/systemd/system/"
|
||||
echo -e "${GREEN}Copied service file to /etc/systemd/system/${NC}"
|
||||
|
||||
# Reload systemd
|
||||
systemctl daemon-reload
|
||||
echo -e "${GREEN}Reloaded systemd configuration${NC}"
|
||||
|
||||
echo -e "${YELLOW}Step 6: Enabling service${NC}"
|
||||
# Enable service to start on boot
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
echo -e "${GREEN}Enabled $SERVICE_NAME service${NC}"
|
||||
|
||||
echo -e "${YELLOW}Step 7: Installing Python dependencies in virtual environment${NC}"
|
||||
print_section "Step 7: Setting Up Python Virtual Environment"
|
||||
print_info "Creating an isolated Python environment for the bot"
|
||||
print_info "This ensures dependencies don't conflict with system Python packages"
|
||||
# Create virtual environment
|
||||
if [ -d "$INSTALL_DIR/venv" ]; then
|
||||
print_warning "Virtual environment already exists, removing it..."
|
||||
rm -rf "$INSTALL_DIR/venv"
|
||||
fi
|
||||
python3 -m venv "$INSTALL_DIR/venv"
|
||||
echo -e "${GREEN}Created virtual environment${NC}"
|
||||
print_success "Created virtual environment at $INSTALL_DIR/venv"
|
||||
|
||||
# Upgrade pip first
|
||||
print_info "Upgrading pip to latest version"
|
||||
"$INSTALL_DIR/venv/bin/pip" install --quiet --upgrade pip >/dev/null 2>&1 || true
|
||||
|
||||
# Install dependencies in venv
|
||||
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
|
||||
echo -e "${GREEN}Installed Python dependencies${NC}"
|
||||
print_info "Installing Python dependencies from requirements.txt"
|
||||
print_info "This may take a few minutes depending on your internet connection..."
|
||||
if [ ! -f "$INSTALL_DIR/requirements.txt" ]; then
|
||||
print_error "requirements.txt not found in installation directory"
|
||||
exit 1
|
||||
fi
|
||||
"$INSTALL_DIR/venv/bin/pip" install --quiet -r "$INSTALL_DIR/requirements.txt" || {
|
||||
print_error "Failed to install Python dependencies"
|
||||
print_info "You may need to check your internet connection or Python version"
|
||||
exit 1
|
||||
}
|
||||
print_success "Installed all Python dependencies"
|
||||
|
||||
# Update ownership of venv
|
||||
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR/venv"
|
||||
print_success "Set ownership of virtual environment"
|
||||
|
||||
print_section "Installation Complete!"
|
||||
echo ""
|
||||
echo -e "${GREEN}Installation completed successfully!${NC}"
|
||||
print_success "MeshCore Bot has been successfully installed as a systemd service!"
|
||||
echo ""
|
||||
echo -e "${BLUE}Service Management Commands:${NC}"
|
||||
echo " Start service: sudo systemctl start $SERVICE_NAME"
|
||||
echo " Stop service: sudo systemctl stop $SERVICE_NAME"
|
||||
echo " Restart service: sudo systemctl restart $SERVICE_NAME"
|
||||
echo " Check status: sudo systemctl status $SERVICE_NAME"
|
||||
echo " View logs: sudo journalctl -u $SERVICE_NAME -f"
|
||||
echo " Disable service: sudo systemctl disable $SERVICE_NAME"
|
||||
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE}📋 Next Steps${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Configuration:${NC}"
|
||||
echo " Config file: $INSTALL_DIR/config.ini"
|
||||
echo " Log directory: $LOG_DIR"
|
||||
echo " Working directory: $INSTALL_DIR"
|
||||
echo -e "${CYAN}1. Configure the bot:${NC}"
|
||||
echo " ${YELLOW}sudo nano $INSTALL_DIR/config.ini${NC}"
|
||||
echo " Edit the configuration file with your bot settings, API keys, and device information"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo "1. Edit configuration: sudo nano $INSTALL_DIR/config.ini"
|
||||
echo "2. Start the service: sudo systemctl start $SERVICE_NAME"
|
||||
echo "3. Check status: sudo systemctl status $SERVICE_NAME"
|
||||
|
||||
if [[ "$IS_MACOS" == true ]]; then
|
||||
echo -e "${CYAN}2. Start the service:${NC}"
|
||||
echo " ${YELLOW}sudo launchctl load -w $LAUNCHD_DIR/$SERVICE_FILE${NC}"
|
||||
echo " Or: ${YELLOW}sudo launchctl start $PLIST_NAME${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}3. Verify it's running:${NC}"
|
||||
echo " ${YELLOW}sudo launchctl list | grep $PLIST_NAME${NC}"
|
||||
echo " Or check logs: ${YELLOW}tail -f $LOG_DIR/meshcore-bot.log${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}4. View live logs (optional):${NC}"
|
||||
echo " ${YELLOW}tail -f $LOG_DIR/meshcore-bot.log${NC}"
|
||||
echo " Press Ctrl+C to exit log view"
|
||||
else
|
||||
echo -e "${CYAN}2. Start the service:${NC}"
|
||||
echo " ${YELLOW}sudo systemctl start $SERVICE_NAME${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}3. Verify it's running:${NC}"
|
||||
echo " ${YELLOW}sudo systemctl status $SERVICE_NAME${NC}"
|
||||
echo " You should see 'active (running)' in green"
|
||||
echo ""
|
||||
echo -e "${CYAN}4. View live logs (optional):${NC}"
|
||||
echo " ${YELLOW}sudo journalctl -u $SERVICE_NAME -f${NC}"
|
||||
echo " Press Ctrl+C to exit log view"
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${GREEN}The bot will now start automatically on system boot!${NC}"
|
||||
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE}🔧 Service Management Commands${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
|
||||
if [[ "$IS_MACOS" == true ]]; then
|
||||
echo -e " ${CYAN}Start service:${NC} ${YELLOW}sudo launchctl start $PLIST_NAME${NC}"
|
||||
echo -e " ${CYAN}Stop service:${NC} ${YELLOW}sudo launchctl stop $PLIST_NAME${NC}"
|
||||
echo -e " ${CYAN}Restart service:${NC} ${YELLOW}sudo launchctl stop $PLIST_NAME && sudo launchctl start $PLIST_NAME${NC}"
|
||||
echo -e " ${CYAN}Check status:${NC} ${YELLOW}sudo launchctl list | grep $PLIST_NAME${NC}"
|
||||
echo -e " ${CYAN}View logs:${NC} ${YELLOW}tail -f $LOG_DIR/meshcore-bot.log${NC}"
|
||||
echo -e " ${CYAN}View error logs:${NC} ${YELLOW}tail -f $LOG_DIR/meshcore-bot.error.log${NC}"
|
||||
echo -e " ${CYAN}Unload service:${NC} ${YELLOW}sudo launchctl unload $LAUNCHD_DIR/$SERVICE_FILE${NC}"
|
||||
echo -e " ${CYAN}Load service:${NC} ${YELLOW}sudo launchctl load $LAUNCHD_DIR/$SERVICE_FILE${NC}"
|
||||
else
|
||||
echo -e " ${CYAN}Start service:${NC} ${YELLOW}sudo systemctl start $SERVICE_NAME${NC}"
|
||||
echo -e " ${CYAN}Stop service:${NC} ${YELLOW}sudo systemctl stop $SERVICE_NAME${NC}"
|
||||
echo -e " ${CYAN}Restart service:${NC} ${YELLOW}sudo systemctl restart $SERVICE_NAME${NC}"
|
||||
echo -e " ${CYAN}Check status:${NC} ${YELLOW}sudo systemctl status $SERVICE_NAME${NC}"
|
||||
echo -e " ${CYAN}View logs:${NC} ${YELLOW}sudo journalctl -u $SERVICE_NAME -f${NC}"
|
||||
echo -e " ${CYAN}View recent logs:${NC} ${YELLOW}sudo journalctl -u $SERVICE_NAME -n 100${NC}"
|
||||
echo -e " ${CYAN}Disable auto-start:${NC} ${YELLOW}sudo systemctl disable $SERVICE_NAME${NC}"
|
||||
echo -e " ${CYAN}Enable auto-start:${NC} ${YELLOW}sudo systemctl enable $SERVICE_NAME${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE}📁 Important File Locations${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}Configuration file:${NC} ${YELLOW}$INSTALL_DIR/config.ini${NC}"
|
||||
echo -e " ${CYAN}Log directory:${NC} ${YELLOW}$LOG_DIR${NC}"
|
||||
echo -e " ${CYAN}Installation directory:${NC} ${YELLOW}$INSTALL_DIR${NC}"
|
||||
if [[ "$IS_MACOS" == true ]]; then
|
||||
echo -e " ${CYAN}Service plist:${NC} ${YELLOW}$LAUNCHD_DIR/$SERVICE_FILE${NC}"
|
||||
else
|
||||
echo -e " ${CYAN}Service file:${NC} ${YELLOW}$SYSTEMD_DIR/$SERVICE_NAME.service${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE}ℹ️ Additional Information${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
print_info "The service is configured to:"
|
||||
echo " • Start automatically on system boot"
|
||||
if [[ "$IS_MACOS" == true ]]; then
|
||||
echo " • Restart automatically if it crashes (with 10 second throttle)"
|
||||
echo " • Run as user '$SERVICE_USER'"
|
||||
echo " • Log to: $LOG_DIR/meshcore-bot.log"
|
||||
echo ""
|
||||
print_info "After editing config.ini, restart the service for changes to take effect:"
|
||||
echo " ${YELLOW}sudo launchctl stop $PLIST_NAME && sudo launchctl start $PLIST_NAME${NC}"
|
||||
else
|
||||
echo " • Restart automatically if it crashes (with 10 second delay)"
|
||||
echo " • Run as user '$SERVICE_USER' for security"
|
||||
echo " • Log to systemd journal (view with journalctl)"
|
||||
echo ""
|
||||
print_info "After editing config.ini, restart the service for changes to take effect:"
|
||||
echo " ${YELLOW}sudo systemctl restart $SERVICE_NAME${NC}"
|
||||
fi
|
||||
echo ""
|
||||
print_success "Installation complete! The bot is ready to configure and start."
|
||||
echo ""
|
||||
@@ -0,0 +1,929 @@
|
||||
#!/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."""
|
||||
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."""
|
||||
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) -> datetime:
|
||||
"""Parse ISO timestamp to datetime and convert to local time."""
|
||||
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."""
|
||||
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"""
|
||||
|
||||
# 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
|
||||
|
||||
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"""
|
||||
# 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"""
|
||||
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)"""
|
||||
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"""
|
||||
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"""
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Tuple of (query_type, location, lat, lon)
|
||||
query_type: "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"""
|
||||
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"""
|
||||
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).
|
||||
Returns 0 if no match, higher numbers for better matches.
|
||||
|
||||
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).
|
||||
"""
|
||||
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 using simple substring matching on address"""
|
||||
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)"""
|
||||
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.
|
||||
This maintains distance ordering while sorting by time as secondary criteria.
|
||||
"""
|
||||
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"""
|
||||
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:
|
||||
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"""
|
||||
# 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"""
|
||||
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]):
|
||||
"""Send up to 10 incidents in multiple messages, grouping efficiently"""
|
||||
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"""
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user