From 4af06c5bdee3bf1b2c7147d39b30e86dd29c5c4e Mon Sep 17 00:00:00 2001 From: chrisdavis2110 Date: Wed, 6 May 2026 22:16:22 -0700 Subject: [PATCH 1/3] added watchduty integration and commands --- config.ini.example | 141 ++++--- modules/commands/evac_command.py | 138 +++++++ modules/commands/fire_command.py | 116 ++++++ modules/config_validation.py | 1 + modules/db_manager.py | 3 + modules/db_migrations.py | 32 ++ modules/scheduler.py | 367 +++++++++++++++++- modules/watchduty_poll.py | 640 +++++++++++++++++++++++++++++++ tests/test_db_migrations.py | 13 + 9 files changed, 1401 insertions(+), 50 deletions(-) create mode 100644 modules/commands/evac_command.py create mode 100644 modules/commands/fire_command.py create mode 100644 modules/watchduty_poll.py diff --git a/config.ini.example b/config.ini.example index 67d674e3..8a3525d9 100644 --- a/config.ini.example +++ b/config.ini.example @@ -83,7 +83,7 @@ message_correlation_timeout = 10.0 enable_enhanced_correlation = true # Bot node ID (leave empty for auto-assignment) -node_id = +node_id = # Command prefix (optional) # If set, all commands must start with this prefix (e.g., "!", ".", "b", "abc") @@ -144,7 +144,7 @@ dm_flood_after = 2 # Timezone for bot operations # Use standard timezone names (e.g., America/New_York, Europe/London, UTC) # Leave empty to use system timezone -timezone = +timezone = # Bot location for geographic proximity calculations and astronomical data # Default latitude for bot location (decimal degrees) @@ -260,7 +260,7 @@ prefix_bytes = 1 [Banned_Users] # List of banned sender names (comma-separated). Matching is prefix (starts-with): # "Awful Username" also matches "Awful Username ๐Ÿ†". No bot responses in channels or DMs. -banned_users = +banned_users = [Localization] # Language code for bot responses (en, es, es-MX, es-ES, fr, de, ja, etc.) @@ -285,13 +285,13 @@ translation_path = translations/ # - Public keys MUST be exactly 64 hexadecimal characters (ed25519 format) # - Invalid formats will be rejected with error logs # - Empty or whitespace-only values disable admin access -# - Keys are case-insensitive (normalized to lowercase) +# - Keys are case-insensitive (normalized to lowercase) # # Format: comma-separated list of 64-character hex public keys (without spaces) # Example: f5d2b56d19b24412756933e917d4632e088cdd5daeadc9002feca73bf5d2b56d,1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef # # IMPORTANT: Leave blank to disable all admin commands. Set your actual admin pubkey(s) here. -admin_pubkeys = +admin_pubkeys = # Commands that require admin access (comma-separated) # These commands will only work for users in the admin_pubkeys list @@ -304,13 +304,13 @@ admin_commands = repeater,webviewer,reload,channelpause # Format: command_name = alternative_file_name # The alternative_file_name should be the name of a Python file (without .py extension) # in the modules/commands/alternatives/ directory -# +# # Example: To use an alternative weather plugin for international users: # wx = wx_international -# +# # This will replace the default wx command with the plugin from # modules/commands/alternatives/wx_international.py -# +# # Note: The alternative plugin must have the same 'name' metadata as the command # it's replacing, or the override will use the alternative plugin's name instead. # @@ -360,7 +360,7 @@ companion_min_inactive_days = 30 # # To use a literal backslash + n, use \\n (double backslash + n) # Other escape sequences: \t (tab), \r (carriage return), \\ (literal backslash) -# +# # Available placeholders (mesh network info - same as Scheduled_Messages): # Total counts (ever heard): # {total_contacts} - Total number of contacts ever heard @@ -368,23 +368,23 @@ companion_min_inactive_days = 30 # {total_companions} - Total number of companion devices ever heard # {total_roomservers} - Total number of roomserver devices ever heard # {total_sensors} - Total number of sensor devices ever heard -# +# # Recent activity: # {recent_activity_24h} - Number of unique users active in last 24 hours -# +# # Active in last 30 days (last_heard): # {total_contacts_30d} - Total contacts active (last_heard) in last 30 days # {total_repeaters_30d} - Total repeaters active (last_heard) in last 30 days # {total_companions_30d} - Total companions active (last_heard) in last 30 days # {total_roomservers_30d} - Total roomservers active (last_heard) in last 30 days # {total_sensors_30d} - Total sensors active (last_heard) in last 30 days -# +# # New devices (first heard in last 7 days): # {new_companions_7d} - New companion devices first heard in last 7 days # {new_repeaters_7d} - New repeater devices first heard in last 7 days # {new_roomservers_7d} - New roomserver devices first heard in last 7 days # {new_sensors_7d} - New sensor devices first heard in last 7 days -# +# # Legacy placeholders (for backward compatibility): # {repeaters} - Same as {total_repeaters} # {companions} - Same as {total_companions} @@ -441,36 +441,36 @@ category.funfact = fun # # Newlines: use \n in the message for a line break (e.g. general:Line one\nLine two). # Literal backslash: use \\n for backslash+n; \\t for tab. -# +# # Available placeholders for mesh network information: -# +# # Total counts (ever heard): # {total_contacts} - Total number of contacts ever heard # {total_repeaters} - Total number of repeater devices ever heard # {total_companions} - Total number of companion devices ever heard # {total_roomservers} - Total number of roomserver devices ever heard # {total_sensors} - Total number of sensor devices ever heard -# +# # Recent activity: # {recent_activity_24h} - Number of unique users active in last 24 hours -# +# # Active in last 30 days (last_heard): # {total_contacts_30d} - Total contacts active (last_heard) in last 30 days # {total_repeaters_30d} - Total repeaters active (last_heard) in last 30 days # {total_companions_30d} - Total companions active (last_heard) in last 30 days # {total_roomservers_30d} - Total roomservers active (last_heard) in last 30 days # {total_sensors_30d} - Total sensors active (last_heard) in last 30 days -# +# # New devices (first heard in last 7 days): # {new_companions_7d} - New companion devices first heard in last 7 days # {new_repeaters_7d} - New repeater devices first heard in last 7 days # {new_roomservers_7d} - New roomserver devices first heard in last 7 days # {new_sensors_7d} - New sensor devices first heard in last 7 days -# +# # Legacy placeholders (for backward compatibility): # {repeaters} - Same as {total_repeaters} # {companions} - Same as {total_companions} -# +# # Example with placeholders (cron): # 0 8 * * * = Public:Good morning! Network: {total_contacts} total ({total_repeaters} repeaters, {total_companions} companions). {new_repeaters_7d} new repeaters, {new_companions_7d} new companions in last 7d. {recent_activity_24h} active in 24h. # Example with 30-day active devices and new devices in 7d: @@ -492,6 +492,41 @@ dm_only = true # aliases = comma-separated list of additional trigger words for this command # aliases = s, sched +[WatchDuty] +# Send Watch Duty fire roundup file to a channel (e.g. from watchduty-incidents2.py) +# Enable reading output file and sending to the configured channel +enabled = false +# Path to the watchduty.txt (or similar) output file from watchduty-incidents2.py +output_file = +# Default channel name (file roundup and fallback when feed/report channels are unset) +channel = #bot +# When poll_api = true: optional dedicated channels (must exist on the device). +# feed_channel: name, acreage, containment, location, link โ€” only when acreage or containment changed since last feed send. +# report_channel: approved report summaries (first poll sends latest report only, then new reports as they appear). +# If unset, both use channel above. +# feed_channel = +# report_channel = +# If true, feed channel sends only the initial summary for each incident (no later acreage/containment feed updates). +# Default false keeps change-based feed updates enabled. +feed_initial_only = false +# Channel PSK in hex (32 chars). Add this channel to your device with this key to receive messages. +# This value is for reference; the channel must be configured on the device separately. +channel_secret = c72a3363c4cdd8a3677e5a5a4558aab3 +# How often to check the file and send if updated (seconds) +# Also controls how often the WatchDuty API is polled when poll_api = true +# Default: 300 (5 minutes) +check_interval_seconds = 300 +# Minimum acreage for WatchDuty events when poll_api = true. +# Only active incidents with known acreage >= this value are included (missing acreage is excluded). +# Default: 1.0 (1 acre). Set higher to reduce smaller incidents, or lower (e.g. 0.1) for more sensitivity. +min_acres = 1.0 +# If true, suppress further WatchDuty alerts for an incident when latest report indicates +# forward progress has stopped (for example "no forward progress"). +stop_alerts_when_forward_progress_stops = true +# Poll Watch Duty API (see feed_channel / report_channel; falls back to channel) +poll_api = false +# Bounding box to only consider events inside (lat_min,lng_min,lat_max,lng_max). Leave empty for all events. +bbox = 32.0,-120.5,35.5,-114.0 [Logging] # Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL @@ -536,7 +571,7 @@ log_backup_count = 3 # {phrase}: The text after the trigger (for custom syntax patterns) # {path_distance}: Total distance between all hops in path with locations (e.g., "123.4km (3 segs, 1 no-loc)") # {firstlast_distance}: Distance between first and last repeater in path (e.g., "45.6km" or empty if locations missing) -# +# # Note: The "t" command is now handled by the test command as an alias # "t phrase" works the same as "test phrase" - both use the test response format # Example: "t hello world" -> "ack {sender}: hello world | {connection_info}" @@ -546,38 +581,38 @@ log_backup_count = 3 # See https://v.gd/apishorteningreference.php short_url_website = https://v.gd # Optional API key for alternate shortener hosts (unused for public v.gd/is.gd) -short_url_website_api_key = +short_url_website_api_key = # Weather API key (future feature) -weather_api_key = +weather_api_key = # Weather update interval in seconds (future feature) weather_update_interval = 3600 # Tide API key (future feature) -tide_api_key = +tide_api_key = # Tide update interval in seconds (future feature) tide_update_interval = 1800 # N2YO API key for satellite pass information # Get free key at: https://www.n2yo.com/login/ -n2yo_api_key = +n2yo_api_key = # AirNow API key for AQI data # Get free key at: https://docs.airnowapi.org/ -airnow_api_key = +airnow_api_key = # Forecast.Solar API key for solar forecast data # Get key at: https://forecast.solar/ (free tier works without key, paid tier for 3+ day forecasts) # Free tier: 2-day forecast, 1-hour resolution # Paid tier (14 EUR/year): 3-6 day forecast, 15-30 minute resolution -forecast_solar_api_key = +forecast_solar_api_key = # Repeater prefix API URL for prefix command # Leave empty to disable prefix command functionality # Configure your own regional API endpoint -repeater_prefix_api_url = +repeater_prefix_api_url = # Repeater prefix cache duration in hours # How long to cache prefix data before refreshing from API @@ -645,7 +680,7 @@ prefix_best_location_radius_km = 50 # Useful for excluding major infrastructure repeaters or reserved prefixes # Example: prefix_best_do_not_suggest = 00,FF,01,02 # Leave empty to allow all prefixes -prefix_best_do_not_suggest = +prefix_best_do_not_suggest = [Weather] # NOTE: Unit settings are used by both the wx and gwx commands and Weather_Service plugin @@ -887,7 +922,7 @@ path_selection_preset = balanced # Basic Settings # Geographic proximity calculation method -# simple: Use proximity to bot location +# simple: Use proximity to bot location # path: Use proximity to previous/next nodes in the path for more realistic routing (default) proximity_method = path @@ -1134,7 +1169,7 @@ enabled = false # Comma-separated list to restrict to specific channels (only greeter command works there) # Example: channels = general,welcome,newbies # If not specified, uses the channels from [Channels] monitor_channels setting -# channels = +# channels = # Greeting message template (default for all channels) # Available fields: {sender} - the user's name/ID @@ -1149,7 +1184,7 @@ greeting_message = Welcome to the mesh, @[{sender}]! # Example: Public:Welcome to Public channel, @[{sender}]!|general:Welcome to general, @[{sender}]! # Multi-part greetings are supported per channel using pipe (|) separator # Leave empty to use greeting_message for all channels -channel_greetings = +channel_greetings = # Per-channel greetings (tracking behavior) # false: Greet each user only once globally (default - user gets one greeting total) @@ -1200,14 +1235,14 @@ enabled = false # Examples: agency.county1, agency.city1, agency.region1, etc. # Example: County agencies -# agency.county1 = +# agency.county1 = # Example: City agencies (can have multiple entries for the same region) -# agency.city1 = -# agency.city1_alias = +# agency.city1 = +# agency.city1_alias = # Example: Combined region (all counties/agencies) -# agency.region_all = +# agency.region_all = [Announcements_Command] # Enable or disable the announcements command (true/false) @@ -1219,7 +1254,7 @@ enabled = false # Format: comma-separated list of 64-character hex public keys (without spaces) # Example: f5d2b56d19b24412756933e917d4632e088cdd5daeadc9002feca73bf5d2b56d # Leave empty to only use Admin_ACL members -announcements_acl = +announcements_acl = # Default channel for announcements when no channel is specified # Announcements will be sent to this channel if no channel is provided @@ -1392,6 +1427,14 @@ enabled = false # Allow private/internal feed URLs (default false for SSRF safety) allow_private_urls = false +[Fire_Command] +enabled = false +# channels = + +[Evac_Command] +enabled = false +# channels = + #################################################################################################### # # # Web Viewer Configuration # @@ -1465,7 +1508,7 @@ auto_start = false # # Note: Only hashtag channels work here - custom channels with private keys # must be added to the radio itself. -decode_hashtag_channels = +decode_hashtag_channels = #################################################################################################### # # @@ -1482,7 +1525,7 @@ enabled = false # Output file for packet data (optional) # Leave empty to disable file output # Packets will be written as JSON lines -output_file = +output_file = # Verbose output (show JSON packet data in logs) # true: Show packet data in logs @@ -1510,10 +1553,10 @@ advert_require_valid_signature = false # Owner information (for packet analyzer registration) # Owner public key (64-character hex string) -owner_public_key = +owner_public_key = # Owner email address -owner_email = +owner_email = # Private key file path for auth token generation (fallback if device signing unavailable) # Optional - on-device signing is preferred @@ -1521,7 +1564,7 @@ owner_email = # Required only if device doesn't support on-device signing or auth_token_method = python # Note: If not provided and auth_token_method = python, the service will attempt to fetch # the private key from the device automatically -private_key_path = +private_key_path = # Auth token signing method # device: Try on-device signing first, fallback to Python signing (default, recommended) @@ -1572,8 +1615,8 @@ mqtt1_token_audience = mqtt-us-v1.letsmesh.net mqtt1_topic_status = meshcore/{IATA}/{PUBLIC_KEY}/status mqtt1_topic_packets = meshcore/{IATA}/{PUBLIC_KEY}/packets mqtt1_websocket_path = /mqtt -mqtt1_client_id = -mqtt1_upload_packet_types = +mqtt1_client_id = +mqtt1_upload_packet_types = # MQTT Broker 2 - Let's Mesh Analyzer (EU) mqtt2_enabled = true @@ -1586,8 +1629,8 @@ mqtt2_token_audience = mqtt-eu-v1.letsmesh.net mqtt2_topic_status = meshcore/{IATA}/{PUBLIC_KEY}/status mqtt2_topic_packets = meshcore/{IATA}/{PUBLIC_KEY}/packets mqtt2_websocket_path = /mqtt -mqtt2_client_id = -mqtt2_upload_packet_types = +mqtt2_client_id = +mqtt2_upload_packet_types = # Stats and status publishing # Enable stats in status messages @@ -1620,7 +1663,7 @@ api_url = https://map.meshcore.dev/api/v1/uploader/node # If not provided, the service will attempt to fetch the private key from the device # Supports 64-byte orlp format (128 hex chars) or 32-byte seed (64 hex chars) # Required only if device doesn't support private key export -private_key_path = +private_key_path = # Minimum time between re-uploads of same node (seconds) # Prevents uploading the same node too frequently to avoid API spam @@ -1649,10 +1692,10 @@ weather_alarm = 6:00 # Bot position for weather forecasts and alerts # Latitude in decimal degrees -my_position_lat = +my_position_lat = # Longitude in decimal degrees -my_position_lon = +my_position_lon = # Channel for daily weather forecasts # Weather forecasts will be sent to this channel diff --git a/modules/commands/evac_command.py b/modules/commands/evac_command.py new file mode 100644 index 00000000..f6db386e --- /dev/null +++ b/modules/commands/evac_command.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""List Watch Duty evacuation orders for a fire.""" + +from typing import List, Optional, Tuple + +from .base_command import BaseCommand +from ..models import MeshMessage +from .. import watchduty_poll + + +class EvacCommand(BaseCommand): + name = "evac" + keywords = ["evac", "evacs"] + description = "List evacuation info (usage: evac [item #])" + category = "info" + requires_internet = True + cooldown_seconds = 5 + + short_description = "Show Watch Duty evacuation orders, warnings, notes, and zone status lines" + usage = "evac [item #]" + examples = ["evac Woods Fire", "evac 1", "evac 93683", "evac 93817 2"] + + def __init__(self, bot): + super().__init__(bot) + self._enabled = self.get_config_value( + "Evac_Command", "enabled", fallback=False, value_type="bool" + ) + self._include_prescribed = self.get_config_value( + "Fires_Command", "include_prescribed", fallback=False, value_type="bool" + ) + + def can_execute(self, message: MeshMessage) -> bool: + if not self._enabled: + return False + return super().can_execute(message) + + def _args_tail(self, message: MeshMessage) -> str: + content = message.content.strip() + if self._command_prefix and content.startswith(self._command_prefix): + content = content[len(self._command_prefix) :].strip() + elif content.startswith("!"): + content = content[1:].strip() + content = self._strip_mentions(content) + parts = content.split() + if not parts: + return "" + kws = {x.lower() for x in self.keywords} + if parts[0].lower() not in kws: + return "" + return " ".join(parts[1:]).strip() + + @staticmethod + def _parse_event_and_item_query(tail: str) -> Tuple[str, Optional[int]]: + """ + Parse ``evac`` args into event query and optional evac item number. + Supports: ``evac `` and ``evac ``. + """ + text = (tail or "").strip() + if not text: + return "", None + parts = text.split() + if len(parts) >= 2 and parts[-1].isdigit(): + item_n = int(parts[-1]) + if item_n >= 1: + return " ".join(parts[:-1]).strip(), item_n + return text, None + + async def execute(self, message: MeshMessage) -> bool: + tail = self._args_tail(message) + event_query, item_n = self._parse_event_and_item_query(tail) + if not event_query: + return await self.send_response( + message, + "Usage: evac [item #] โ€” same list as fires; id from app.watchduty.org/i/", + ) + + try: + events = watchduty_poll.fetch_active_geo_events_for_user_query( + self.bot.config, + include_prescribed=self._include_prescribed, + ) + except Exception as e: + self.logger.error("evac command: fetch failed: %s", e) + return await self.send_response(message, "Could not load fires (Watch Duty).") + + event, err = watchduty_poll.resolve_active_event_by_query( + events, + event_query, + config=self.bot.config, + include_prescribed=self._include_prescribed, + ) + if err: + if err == "usage": + return await self.send_response( + message, + "Usage: evac [item #] โ€” same list as fires; id from app.watchduty.org/i/", + ) + return await self.send_response(message, err) + + eid = event.get("id") + if eid is None: + return await self.send_response(message, "Invalid event (missing id).") + + detail = watchduty_poll.fetch_event_detail(int(eid)) + if not detail: + detail = event + name = (detail.get("name") or f"Event {eid}").strip() + + lines_body = watchduty_poll.evacuation_display_lines(detail) + max_len = self.get_max_message_length(message) + + if not lines_body: + msg = f"No evacuation info listed for {name} on Watch Duty." + return await self.send_response(message, msg[:max_len]) + + if item_n is not None: + if item_n > len(lines_body): + return await self.send_response( + message, + f"Only {len(lines_body)} evacuation item(s) for {name}. Try: evac {eid}", + ) + full_text = lines_body[item_n - 1] + lines: List[str] = [f"Evac โ€” {name}:", f"{item_n}. {full_text}"] + else: + lines = [f"Evac โ€” {name}:"] + for i, line in enumerate(lines_body, start=1): + snippet = watchduty_poll.first_sentence(line) + if not snippet: + continue + if snippet != line and len(snippet) < len(line): + snippet = snippet.rstrip() + " [...]" + lines.append(f"{i}. {snippet}") + lines.append("Use: evac <#> for full text.") + + chunks = watchduty_poll.mesh_pack_lines(lines, max_len) + if len(chunks) == 1: + return await self.send_response(message, chunks[0]) + return await self.send_response_chunked(message, chunks) diff --git a/modules/commands/fire_command.py b/modules/commands/fire_command.py new file mode 100644 index 00000000..454739fd --- /dev/null +++ b/modules/commands/fire_command.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""List active fires or show one fire's details.""" + +from typing import List + +from .base_command import BaseCommand +from ..models import MeshMessage +from .. import watchduty_poll + + +class FireCommand(BaseCommand): + name = "fire" + keywords = ["fire"] + description = "List active fires or show details (usage: fire [list #|Watch Duty id|name])" + category = "info" + requires_internet = True + cooldown_seconds = 5 + + short_description = "List active fires when no args; show one fire detail with an argument" + usage = "fire [list #|Watch Duty id|name]" + examples = ["fire", "fire 1", "fire 93683", "fire Woods Fire"] + + def __init__(self, bot): + super().__init__(bot) + self._enabled = self.get_config_value( + "Fire_Command", "enabled", fallback=False, value_type="bool" + ) + self._include_prescribed = self.get_config_value( + "Fires_Command", "include_prescribed", fallback=False, value_type="bool" + ) + + def can_execute(self, message: MeshMessage) -> bool: + if not self._enabled: + return False + return super().can_execute(message) + + def _args_tail(self, message: MeshMessage) -> str: + content = message.content.strip() + if self._command_prefix and content.startswith(self._command_prefix): + content = content[len(self._command_prefix) :].strip() + elif content.startswith("!"): + content = content[1:].strip() + content = self._strip_mentions(content) + parts = content.split() + if not parts: + return "" + kws = {x.lower() for x in self.keywords} + if parts[0].lower() not in kws: + return "" + return " ".join(parts[1:]).strip() + + async def execute(self, message: MeshMessage) -> bool: + tail = self._args_tail(message) + try: + events = watchduty_poll.fetch_active_geo_events_for_user_query( + self.bot.config, + include_prescribed=self._include_prescribed, + ) + except Exception as e: + self.logger.error("fire command: fetch failed: %s", e) + return await self.send_response(message, "Could not load fires (Watch Duty).") + if not tail: + if not events: + return await self.send_response(message, "No active fires match the current filter.") + max_len = self.get_max_message_length(message) + lines: List[str] = [f"Active fires ({len(events)}):"] + for i, event in enumerate(events, start=1): + name = (event.get("name") or f"Event {event.get('id')}").strip() + loc = watchduty_poll.format_location_short(event) + eid = event.get("id") + id_part = f" ยท {eid}" if eid is not None else "" + lines.append(f"{i}. {name} ({loc}){id_part}") + chunks = watchduty_poll.mesh_pack_lines(lines, max_len) + if len(chunks) == 1: + return await self.send_response(message, chunks[0]) + return await self.send_response_chunked(message, chunks) + + event, err = watchduty_poll.resolve_active_event_by_query( + events, + tail, + config=self.bot.config, + include_prescribed=self._include_prescribed, + ) + if err: + if err == "usage": + return await self.send_response( + message, + "Usage: fire [list #|Watch Duty id|name] โ€” ids match app.watchduty.org/i/.", + ) + return await self.send_response(message, err) + + eid = event.get("id") + if eid is None: + return await self.send_response(message, "Invalid event (missing id).") + + detail = watchduty_poll.fetch_event_detail(int(eid)) + if not detail: + detail = event + name = (detail.get("name") or f"Event {eid}").strip() + acres = watchduty_poll.get_event_acres(detail) + acres_s = f"{acres:g} ac" if acres is not None else "acres: unknown" + containment = watchduty_poll.format_containment_display(detail) + location = watchduty_poll.format_location(detail) + evac_count = watchduty_poll.evacuation_display_count(detail) + + lines = [ + name, + f"{acres_s} | {containment} contained", + f"{location}", + f"evacs: {evac_count}", + ] + max_len = self.get_max_message_length(message) + chunks = watchduty_poll.mesh_pack_lines(lines, max_len) + if len(chunks) == 1: + return await self.send_response(message, chunks[0]) + return await self.send_response_chunked(message, chunks) diff --git a/modules/config_validation.py b/modules/config_validation.py index 0566ba2a..59f2edca 100644 --- a/modules/config_validation.py +++ b/modules/config_validation.py @@ -59,6 +59,7 @@ def _channel_name_is_public(name: str) -> bool: "MapUploader", "Weather_Service", "DiscordBridge", + "WatchDuty", }) # Sections required for the bot to start (accessed without has_section guards) diff --git a/modules/db_manager.py b/modules/db_manager.py index 8d214ab9..9bd15081 100644 --- a/modules/db_manager.py +++ b/modules/db_manager.py @@ -50,6 +50,9 @@ class DBManager: 'purging_log', # Repeater manager 'mesh_connections', # Mesh graph for path validation 'observed_paths', # Repeater manager - observed paths from adverts and messages + 'watchduty_sent_reports', # WatchDuty API poll - report IDs already sent to mesh + 'watchduty_feed_state', # WatchDuty last feed line (acreage/containment) per event + 'watchduty_alert_suppression', # WatchDuty per-event suppression state } def __init__(self, bot: Any, db_path: str = "meshcore_bot.db"): diff --git a/modules/db_migrations.py b/modules/db_migrations.py index b04b57f1..86fce8a2 100644 --- a/modules/db_migrations.py +++ b/modules/db_migrations.py @@ -490,6 +490,37 @@ def _m0012_purging_log_details_column(cursor: sqlite3.Cursor) -> None: _add_column(cursor, "purging_log", "details", "TEXT") +def _m0013_watchduty_tables(cursor: sqlite3.Cursor) -> None: + """Create WatchDuty state tables used by wildfire polling and alerts.""" + cursor.executescript( + """ + CREATE TABLE IF NOT EXISTS watchduty_sent_reports ( + event_id INTEGER NOT NULL, + report_id INTEGER NOT NULL, + sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (event_id, report_id) + ); + + CREATE INDEX IF NOT EXISTS idx_watchduty_sent_reports_event + ON watchduty_sent_reports(event_id); + + CREATE TABLE IF NOT EXISTS watchduty_feed_state ( + event_id INTEGER PRIMARY KEY, + last_acres REAL NOT NULL, + last_containment TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS watchduty_alert_suppression ( + event_id INTEGER PRIMARY KEY, + suppressed INTEGER NOT NULL DEFAULT 0, + reason TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + + # --------------------------------------------------------------------------- # Migration registry โ€” append new entries here, never remove or reorder. # --------------------------------------------------------------------------- @@ -509,6 +540,7 @@ def _m0012_purging_log_details_column(cursor: sqlite3.Cursor) -> None: (10, "create repeater/graph tables", _m0010_create_repeater_and_graph_tables), (11, "repeater/graph indexes", _m0011_repeater_and_graph_indexes), (12, "purging_log: add details column", _m0012_purging_log_details_column), + (13, "watchduty tables", _m0013_watchduty_tables), ] diff --git a/modules/scheduler.py b/modules/scheduler.py index ad39bd03..e4f1c5b0 100644 --- a/modules/scheduler.py +++ b/modules/scheduler.py @@ -23,6 +23,7 @@ from .scheduled_message_cron import is_valid_legacy_hhmm, parse_schedule_key from .security_utils import validate_external_url from .utils import decode_escape_sequences, format_keyword_response_with_placeholders, get_config_timezone +from . import watchduty_poll # process_message_queue may await long per-feed intervals across many queued items; 30s is too short. _FEED_MESSAGE_QUEUE_FUTURE_TIMEOUT = 600 @@ -47,6 +48,7 @@ def __init__(self, bot): self.last_db_backup_run = 0 self.last_log_rotation_check_time = 0 self.maintenance = MaintenanceRunner(bot, get_current_time=self.get_current_time) + self.last_watchduty_check_time = 0 def get_current_time(self): """Get current time in configured timezone""" @@ -488,6 +490,36 @@ def run_scheduler(self): # Check for interval-based advertising self.check_interval_advertising() + # WatchDuty: file-based roundup and/or API poll for new fires/reports + if (self._watchduty_enabled() or self._watchduty_poll_api_enabled()) and time.time() - self.last_watchduty_check_time >= self._watchduty_interval(): + if hasattr(self.bot, 'connected') and self.bot.connected: + import asyncio + async def _run_watchduty_tasks(): + if self._watchduty_enabled(): + await self._send_watchduty_if_updated_async() + if self._watchduty_poll_api_enabled(): + await self._watchduty_poll_and_send_async() + if hasattr(self.bot, 'main_event_loop') and self.bot.main_event_loop and self.bot.main_event_loop.is_running(): + future = asyncio.run_coroutine_threadsafe( + _run_watchduty_tasks(), + self.bot.main_event_loop + ) + try: + future.result(timeout=120) + except Exception as e: + self.logger.error(f"WatchDuty error: {e}") + else: + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(_run_watchduty_tasks()) + except Exception as e: + self.logger.error(f"WatchDuty error: {e}") + self.last_watchduty_check_time = time.time() + # Poll feeds every minute (but feeds themselves control their check intervals) if time.time() - last_feed_poll_time >= 60: # Every 60 seconds if (hasattr(self.bot, 'feed_manager') and self.bot.feed_manager and @@ -792,6 +824,340 @@ async def _send_interval_advert_async(self): raise RuntimeError(f"send_advert failed: {reason}") self.logger.info("Interval-based flood advert sent successfully") + def _watchduty_enabled(self): + """Return True if WatchDuty is enabled and configured.""" + if not self.bot.config.has_section('WatchDuty'): + return False + if not self.bot.config.getboolean('WatchDuty', 'enabled', fallback=False): + return False + output_file = self.bot.config.get('WatchDuty', 'output_file', fallback='').strip() + channel = self.bot.config.get('WatchDuty', 'channel', fallback='').strip() + return bool(output_file and channel) + + def _watchduty_interval(self): + """Return WatchDuty check interval in seconds.""" + if not self.bot.config.has_section('WatchDuty'): + return 300 + return self.bot.config.getint('WatchDuty', 'check_interval_seconds', fallback=300) + + def _watchduty_min_acres(self) -> float: + """Return minimum acreage threshold for WatchDuty events.""" + if not self.bot.config.has_section('WatchDuty'): + return 1.0 + # Accept float values; fall back to 1.0 on any error + try: + return self.bot.config.getfloat('WatchDuty', 'min_acres', fallback=1.0) + except Exception: + return 1.0 + + def _watchduty_feed_channel(self) -> str: + """Channel for fire summary (name, acres, containment, location, link). Uses feed_channel, else channel.""" + if not self.bot.config.has_section('WatchDuty'): + return '' + s = self.bot.config.get('WatchDuty', 'feed_channel', fallback='').strip() + if s: + return s + return self.bot.config.get('WatchDuty', 'channel', fallback='').strip() + + def _watchduty_report_channel(self) -> str: + """Channel for approved report lines. Uses report_channel, else channel.""" + if not self.bot.config.has_section('WatchDuty'): + return '' + s = self.bot.config.get('WatchDuty', 'report_channel', fallback='').strip() + if s: + return s + return self.bot.config.get('WatchDuty', 'channel', fallback='').strip() + + async def _send_watchduty_if_updated_async(self): + """Read WatchDuty output file and send to channel if file was updated since last send.""" + if not self._watchduty_enabled(): + return + output_file = self.bot.config.get('WatchDuty', 'output_file', fallback='').strip() + channel = self.bot.config.get('WatchDuty', 'channel', fallback='').strip() + if not output_file or not channel: + return + base = getattr(self.bot, 'bot_root', os.getcwd()) + path = Path(resolve_path(output_file, base)) + sent_marker = path.with_suffix(path.suffix + '.last_sent') + try: + if not path.exists(): + self.logger.debug("WatchDuty output file not found: %s", path) + return + mtime = path.stat().st_mtime + last_sent_mtime = 0.0 + if sent_marker.exists(): + try: + last_sent_mtime = float(sent_marker.read_text(encoding='utf-8').strip()) + except (ValueError, OSError): + pass + if mtime <= last_sent_mtime: + return + content = path.read_text(encoding='utf-8', errors='replace').strip() + if not content: + return + # Mesh payload limit ~237 bytes; send in smaller chunks (break on newlines when possible) + # Use 136 chars to match WatchDuty API poll message formatting + max_chunk = 136 + chunks = [] + for line in content.split('\n'): + if not chunks: + chunks.append(line) + elif len(chunks[-1]) + len(line) + 1 <= max_chunk: + chunks[-1] += '\n' + line + else: + chunks.append(line) + for i, chunk in enumerate(chunks): + if chunk.strip(): + success = await self.bot.command_manager.send_channel_message( + channel, chunk, skip_user_rate_limit=True + ) + if not success: + self.logger.warning("WatchDuty: failed to send chunk %s to %s", i + 1, channel) + return + await asyncio.sleep(max(1.0, self.bot.bot_tx_rate_limiter.seconds)) + sent_marker.write_text(str(mtime), encoding='utf-8') + self.logger.info("WatchDuty: sent %s chunk(s) to %s", len(chunks), channel) + except Exception as e: + self.logger.error("WatchDuty error: %s", e) + + def _watchduty_poll_api_enabled(self): + """Return True if WatchDuty API polling is enabled (poll for new fires/reports).""" + if not self.bot.config.has_section('WatchDuty'): + return False + if not self.bot.config.getboolean('WatchDuty', 'enabled', fallback=False): + return False + if not self.bot.config.getboolean('WatchDuty', 'poll_api', fallback=False): + return False + return bool(self._watchduty_feed_channel() or self._watchduty_report_channel()) + + def _watchduty_bbox(self): + """Return optional (lat_min, lng_min, lat_max, lng_max) from config, or None.""" + if not self.bot.config.has_section('WatchDuty'): + return None + bbox_str = self.bot.config.get('WatchDuty', 'bbox', fallback='').strip() + if not bbox_str: + return None + try: + parts = [float(x.strip()) for x in bbox_str.split(',')] + if len(parts) != 4: + return None + return tuple(parts) + except (ValueError, AttributeError): + return None + + def _watchduty_stop_on_no_forward_progress(self) -> bool: + """When true, suppress WatchDuty alerts after reports indicate no forward progress.""" + if not self.bot.config.has_section('WatchDuty'): + return True + return self.bot.config.getboolean( + 'WatchDuty', + 'stop_alerts_when_forward_progress_stops', + fallback=True, + ) + + def _watchduty_feed_initial_only(self) -> bool: + """When true, send only the initial feed summary per incident.""" + if not self.bot.config.has_section('WatchDuty'): + return False + return self.bot.config.getboolean( + 'WatchDuty', + 'feed_initial_only', + fallback=False, + ) + + async def _watchduty_poll_and_send_async(self): + """Poll Watch Duty API: feed channel when acreage/containment change; report channel for new reports.""" + if not self._watchduty_poll_api_enabled(): + return + feed_channel = self._watchduty_feed_channel() + report_channel = self._watchduty_report_channel() + if not feed_channel and not report_channel: + return + db_path = str(self.bot.db_manager.db_path) + bbox = self._watchduty_bbox() + try: + events = watchduty_poll.fetch_geo_events(bbox=bbox) + except Exception as e: + self.logger.error("WatchDuty poll: fetch geo_events failed: %s", e) + return + # Exclude prescribed burns (comment out next line to exclude them for testing) + events = [e for e in events if not (e.get('data') or {}).get('is_prescribed')] + events = [e for e in events if watchduty_poll.geo_event_is_active(e)] + min_acres = self._watchduty_min_acres() + events = [e for e in events if watchduty_poll.event_meets_min_acres(e, min_acres=min_acres)] + tx_pause = max(1.0, self.bot.bot_tx_rate_limiter.seconds) + with sqlite3.connect(db_path, timeout=30.0) as conn: + cursor = conn.cursor() + for event in events: + event_id = event.get('id') + if event_id is None: + continue + name = (event.get('name') or '').strip() or f"Event {event_id}" + acres = watchduty_poll.get_event_acres(event) + if acres is None: + continue + containment_sig = watchduty_poll.containment_key(event) + containment_disp = watchduty_poll.format_containment_display(event) + location = watchduty_poll.format_location(event) + + suppress_due_to_no_progress = False + if self._watchduty_stop_on_no_forward_progress(): + cursor.execute( + 'SELECT suppressed FROM watchduty_alert_suppression WHERE event_id = ?', + (event_id,), + ) + suppression_row = cursor.fetchone() + is_suppressed = bool(suppression_row and int(suppression_row[0]) == 1) + + if report_channel: + try: + reports_for_progress = watchduty_poll.fetch_reports(event_id) + except Exception as e: + self.logger.warning( + "WatchDuty poll: fetch reports for event %s failed: %s", + event_id, + e, + ) + reports_for_progress = [] + if reports_for_progress: + latest_plain = watchduty_poll.strip_html( + reports_for_progress[-1].get('message') or '' + ) + if watchduty_poll.indicates_forward_progress_stopped(latest_plain): + if not is_suppressed: + cursor.execute( + ''' + INSERT INTO watchduty_alert_suppression (event_id, suppressed, reason) + VALUES (?, 1, ?) + ON CONFLICT(event_id) DO UPDATE SET + suppressed = 1, + reason = excluded.reason, + updated_at = CURRENT_TIMESTAMP + ''', + (event_id, 'forward_progress_stopped'), + ) + conn.commit() + self.logger.info( + "WatchDuty poll: suppressing alerts for %s (forward progress stopped)", + name, + ) + suppress_due_to_no_progress = True + else: + suppress_due_to_no_progress = is_suppressed + else: + suppress_due_to_no_progress = is_suppressed + else: + suppress_due_to_no_progress = is_suppressed + + if suppress_due_to_no_progress: + continue + + if feed_channel: + cursor.execute( + 'SELECT last_acres, last_containment FROM watchduty_feed_state WHERE event_id = ?', + (event_id,), + ) + feed_row = cursor.fetchone() + feed_changed = watchduty_poll.feed_state_changed(feed_row, acres, containment_sig) + should_send_feed = feed_changed + if self._watchduty_feed_initial_only() and feed_row is not None: + should_send_feed = False + + if should_send_feed: + msg_feed = watchduty_poll.format_feed_summary_message( + name, acres, containment_disp, location, event_id + ) + ok = await self.bot.command_manager.send_channel_message( + feed_channel, msg_feed, skip_user_rate_limit=True + ) + if ok: + cursor.execute( + ''' + INSERT INTO watchduty_feed_state (event_id, last_acres, last_containment) + VALUES (?, ?, ?) + ON CONFLICT(event_id) DO UPDATE SET + last_acres = excluded.last_acres, + last_containment = excluded.last_containment, + updated_at = CURRENT_TIMESTAMP + ''', + (event_id, acres, containment_sig), + ) + conn.commit() + else: + self.logger.warning( + "WatchDuty poll: failed to send feed line for %s to %s", name, feed_channel + ) + await asyncio.sleep(tx_pause) + + if not report_channel: + continue + + cursor.execute( + 'SELECT report_id FROM watchduty_sent_reports WHERE event_id = ?', + (event_id,), + ) + sent_report_ids = {row[0] for row in cursor.fetchall()} + synced_reports = any((rid or 0) > 0 for rid in sent_report_ids) + + if report_channel and self._watchduty_stop_on_no_forward_progress(): + reports = reports_for_progress + else: + try: + reports = watchduty_poll.fetch_reports(event_id) + except Exception as e: + self.logger.warning("WatchDuty poll: fetch reports for event %s failed: %s", event_id, e) + continue + + if not synced_reports: + if not reports: + continue + latest_report = reports[-1] + plain = watchduty_poll.strip_html(latest_report.get('message') or '') + plain = watchduty_poll.first_sentence(plain) + if plain: + msg_r = watchduty_poll.format_report_message(name, plain, event_id=event_id) + ok = await self.bot.command_manager.send_channel_message( + report_channel, msg_r, skip_user_rate_limit=True + ) + if ok: + for r in reports: + rid = r.get('id') + if rid is not None: + cursor.execute( + 'INSERT OR IGNORE INTO watchduty_sent_reports (event_id, report_id) VALUES (?, ?)', + (event_id, rid), + ) + conn.commit() + else: + self.logger.warning( + "WatchDuty poll: failed to send first report for %s to %s", name, report_channel + ) + await asyncio.sleep(tx_pause) + continue + + for report in reports: + report_id = report.get('id') + if report_id is None or report_id in sent_report_ids: + continue + plain = watchduty_poll.strip_html(report.get('message') or '') + plain = watchduty_poll.first_sentence(plain) + if not plain: + continue + msg = watchduty_poll.format_report_message(name, plain, event_id=event_id) + ok = await self.bot.command_manager.send_channel_message( + report_channel, msg, skip_user_rate_limit=True + ) + if ok: + cursor.execute( + 'INSERT OR IGNORE INTO watchduty_sent_reports (event_id, report_id) VALUES (?, ?)', + (event_id, report_id), + ) + conn.commit() + sent_report_ids.add(report_id) + await asyncio.sleep(tx_pause) + self.logger.debug("WatchDuty poll cycle completed") + return + async def _process_channel_operations(self): """Process pending channel operations from the web viewer""" try: @@ -1466,4 +1832,3 @@ def send_radio_offline_alert_email(self, fail_count: int, threshold: int) -> Non self.bot.logger.error(f"Failed to send radio-offline alert email: {e}") # โ”€โ”€ Maintenance helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - diff --git a/modules/watchduty_poll.py b/modules/watchduty_poll.py new file mode 100644 index 00000000..55ab6236 --- /dev/null +++ b/modules/watchduty_poll.py @@ -0,0 +1,640 @@ +#!/usr/bin/env python3 +""" +Watch Duty API polling helpers for meshcore-bot. +Fetches geo_events and reports using the same API/headers as the Watch Duty app. +Used by the scheduler: feed channel (summary when acreage/containment change) and report channel. +""" + +import html +import json +import re +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +import requests + + +def _ts_ms() -> int: + return int(datetime.now(timezone.utc).timestamp() * 1000) + + +def _headers() -> Dict[str, str]: + return { + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en", + "Origin": "https://app.watchduty.org", + "Referer": "https://app.watchduty.org/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15", + "X-App-Is-Native": "false", + "X-App-Version": "2026.2.5", + "X-Git-Tag": "2026.2.5", + } + + +def strip_html(text: str) -> str: + """Remove HTML tags and decode entities for plain-text mesh.""" + if not text: + return "" + text = re.sub(r"<[^>]+>", " ", text) + text = re.sub(r"\s+", " ", text).strip() + return html.unescape(text) + + +def first_sentence(text: str) -> str: + """ + Return only the first sentence from a plain-text string. + + Splits on '.', '!' or '?' followed by whitespace or end-of-string. + Falls back to the full text when no sentence boundary is found. + """ + if not text: + return "" + # Find first sentence terminator followed by space or end + m = re.search(r"([.!?])(\s|$)", text) + if not m: + return text + end_idx = m.end(1) + return text[:end_idx].strip() + + +def indicates_forward_progress_stopped(text: str) -> bool: + """ + Return True when report text indicates fire forward progress has stopped. + """ + if not text: + return False + normalized = " ".join(text.lower().split()) + patterns = ( + "forward progress has stopped", + "forward progress has been stopped", + "forward progress stopped", + "forward progression has stopped", + "forward progression has been stopped", + "forward progression stopped", + "forward spread has stopped", + "spread has stopped", + "no forward progress", + "no significant forward progress", + "no forward progression", + "no significant forward progression", + ) + return any(p in normalized for p in patterns) + +def _inside_bbox(lat: Optional[float], lng: Optional[float], bbox: Optional[Tuple[float, float, float, float]]) -> bool: + """Return True if (lat, lng) is inside (lat_min, lng_min, lat_max, lng_max).""" + if bbox is None or lat is None or lng is None: + return True + lat_min, lng_min, lat_max, lng_max = bbox + return lat_min <= lat <= lat_max and lng_min <= lng <= lng_max + + +def get_event_acres(event: Dict[str, Any]) -> Optional[float]: + """ + Extract acreage from a geo_event if present. + Checks event.data.acres, event.data.size_acres, event.acres. + Returns None if not found or not a valid number. + """ + data = event.get("data") or {} + for key in ("acres", "size_acres", "acreage"): + val = data.get(key) if isinstance(data, dict) else None + if val is None: + val = event.get(key) + if val is not None: + try: + n = float(val) + if n >= 0: + return n + except (TypeError, ValueError): + pass + return None + + +def event_meets_min_acres(event: Dict[str, Any], min_acres: float = 1.0) -> bool: + """ + Return True only when acreage is present and >= min_acres. + Events with missing or invalid acreage are excluded. + """ + acres = get_event_acres(event) + if acres is None: + return False + return acres >= min_acres + + +def geo_event_is_active(event: Dict[str, Any]) -> bool: + """True when the Watch Duty API marks the geo_event as active (is_active == True).""" + return event.get("is_active") is True + + +def containment_key(event: Dict[str, Any]) -> str: + """ + Stable string for comparing containment across polls (empty if unknown/none). + """ + data = event.get("data") or {} + if not isinstance(data, dict): + return "" + c = data.get("containment") + if c is None or c == "": + return "" + if isinstance(c, bool): + return "true" if c else "false" + if isinstance(c, (int, float)): + return f"{float(c):g}" + return str(c).strip() + + +def format_containment_display(event: Dict[str, Any]) -> str: + """Short containment text for mesh (e.g. percent or em dash if unknown).""" + data = event.get("data") or {} + if not isinstance(data, dict): + return "โ€”" + c = data.get("containment") + if c is None or c == "": + return "โ€”" + if isinstance(c, (int, float)): + return f"{float(c):g}%" + return str(c).strip() + + +def feed_state_changed( + last_row: Optional[Tuple[Any, Any]], + acres: float, + containment_sig: str, +) -> bool: + """True if there is no prior state or acreage/containment differ from last feed send.""" + if last_row is None: + return True + last_acres, last_ct = last_row[0], last_row[1] + if last_acres is None or abs(float(last_acres) - float(acres)) > 1e-6: + return True + prev_ct = "" if last_ct is None else str(last_ct) + return prev_ct != containment_sig + + +def format_feed_summary_message( + name: str, + acres: float, + containment_display: str, + location: str, + event_id: int, + max_len: int = 136, +) -> str: + """ + One line: name, acreage, containment, location, then Watch Duty link. + Trims the leading segment to keep the URL intact within max_len (mesh limit). + """ + link = incident_url(event_id) + suffix = f" | {link}" + if len(suffix) > max_len: + return (link[: max_len - 3].rstrip() + "...") if len(link) > max_len else link + available = max_len - len(suffix) + if available <= 0: + return link + core = f"{name} | {acres:g} ac | {containment_display} | {location}" + if len(core) > available: + core = core[: available - 3].rstrip() + "..." + return f"{core}{suffix}" + + +def fetch_geo_events( + bbox: Optional[Tuple[float, float, float, float]] = None, + timeout: int = 30, +) -> List[Dict[str, Any]]: + """ + Fetch geo_events (wildfire, location). Optionally filter by bounding box. + Returns list of event dicts (id, name, lat, lng, address, ...). + """ + url = f"https://api.watchduty.org/api/v1/geo_events/?geo_event_types=wildfire,location&ts={_ts_ms()}" + resp = requests.get(url, headers=_headers(), timeout=timeout) + resp.raise_for_status() + events = resp.json() + if not isinstance(events, list): + return [] + if bbox is None: + return events + return [e for e in events if _inside_bbox(e.get("lat"), e.get("lng"), bbox)] + + +def fetch_event_detail(event_id: int, timeout: int = 30) -> Optional[Dict[str, Any]]: + """Fetch a single geo_event by id. Returns None on 404 or error.""" + url = f"https://api.watchduty.org/api/v1/geo_events/{event_id}?ts={_ts_ms()}" + try: + resp = requests.get(url, headers=_headers(), timeout=timeout) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + except requests.RequestException: + return None + + +def fetch_reports( + event_id: int, + limit: int = 50, + timeout: int = 30, +) -> List[Dict[str, Any]]: + """ + Fetch approved reports for an event. API returns newest first. + Returns list of report dicts (id, message, date_created, ...). + """ + url = ( + f"https://api.watchduty.org/api/v1/reports/" + f"?geo_event_id={event_id}&status=approved&limit={limit}&offset=0&ts={_ts_ms()}" + ) + resp = requests.get(url, headers=_headers(), timeout=timeout) + resp.raise_for_status() + data = resp.json() + results = data.get("results") or [] + # Sort by date_created ascending so oldest (first report) is first + results.sort(key=lambda r: r.get("date_created") or "") + return results + + +# Base URL for Watch Duty app incident pages (geo_event id) +WATCHDUTY_INCIDENT_BASE = "https://app.watchduty.org/i/" + + +def incident_url(event_id: int) -> str: + """Return the Watch Duty app URL for this incident (geo_event).""" + return f"{WATCHDUTY_INCIDENT_BASE}{event_id}" + + +def format_location(event: Dict[str, Any]) -> str: + """Format event location for mesh: address or lat,lng.""" + addr = (event.get("address") or "").strip() + if addr: + return addr + lat = event.get("lat") + lng = event.get("lng") + if lat is not None and lng is not None: + return f"{lat:.4f}, {lng:.4f}" + return "Location unknown" + + +def format_new_fire_message( + name: str, location: str, event_id: Optional[int] = None, max_len: int = 136 +) -> str: + """ + One-line new fire alert for mesh. + + If event_id is set, always keeps the incident URL intact at the end of the line, + trimming the name/location portion as needed to fit within max_len. + """ + base = f"{name} | {location}" + if event_id is None: + msg = base + if len(msg) > max_len: + msg = msg[: max_len - 3].rstrip() + "..." + return msg + + link = incident_url(event_id) + suffix = f" | {link}" + # If even the suffix alone is too long, fall back to raw link truncated + if len(suffix) > max_len: + return (link[: max_len - 3].rstrip() + "...") if len(link) > max_len else link + + # Reserve space for suffix; trim base if necessary + available = max_len - len(suffix) + if available <= 0: + # No room for base text; send just the link + return link + + if len(base) > available: + base = base[: available - 3].rstrip() + "..." + + return f"{base}{suffix}" + + +def watchduty_bbox_from_config(config: Any) -> Optional[Tuple[float, float, float, float]]: + """Parse [WatchDuty] bbox (lat_min,lng_min,lat_max,lng_max) or return None.""" + if not getattr(config, "has_section", lambda _: False)("WatchDuty"): + return None + bbox_str = config.get("WatchDuty", "bbox", fallback="").strip() + if not bbox_str: + return None + try: + parts = [float(x.strip()) for x in bbox_str.split(",")] + if len(parts) != 4: + return None + return tuple(parts) # type: ignore[return-value] + except (ValueError, AttributeError): + return None + + +def watchduty_min_acres_from_config(config: Any) -> float: + """Minimum acres from [WatchDuty] min_acres (default 1.0).""" + if not getattr(config, "has_section", lambda _: False)("WatchDuty"): + return 1.0 + try: + return float(config.getfloat("WatchDuty", "min_acres", fallback=1.0)) + except Exception: + return 1.0 + + +def fetch_active_geo_events_for_user_query( + config: Any, + *, + include_prescribed: bool = False, + timeout: int = 30, +) -> List[Dict[str, Any]]: + """ + Active geo_events suitable for interactive fire commands. + Uses the same bbox and min_acres as [WatchDuty] when that section exists. + """ + bbox = watchduty_bbox_from_config(config) + min_acres = watchduty_min_acres_from_config(config) + events = fetch_geo_events(bbox=bbox, timeout=timeout) + if not isinstance(events, list): + return [] + if not include_prescribed: + events = [e for e in events if not (e.get("data") or {}).get("is_prescribed")] + events = [e for e in events if geo_event_is_active(e)] + events = [e for e in events if event_meets_min_acres(e, min_acres=min_acres)] + events.sort(key=lambda e: (str(e.get("name") or "").lower(), e.get("id") or 0)) + return events + + +def format_location_short(event: Dict[str, Any], max_len: int = 56) -> str: + """ + Short location for compact lists, e.g. last two comma-separated parts of address + (often city, state), else format_location truncated. + """ + addr = (event.get("address") or "").strip() + if addr and "," in addr: + parts = [p.strip() for p in addr.split(",") if p.strip()] + if len(parts) >= 2: + tail = ", ".join(parts[-2:]) + if len(tail) <= max_len: + return tail + loc = format_location(event) + if len(loc) > max_len: + return loc[: max_len - 3].rstrip() + "..." + return loc + + +def event_data(event: Dict[str, Any]) -> Dict[str, Any]: + d = event.get("data") + return d if isinstance(d, dict) else {} + + +def _evac_field_nonempty(data: Dict[str, Any], raw_key: str, has_custom_key: str) -> bool: + """True when Watch Duty sets the custom flag or embeds a non-empty orders/warnings/etc. collection.""" + if data.get(has_custom_key) is True: + return True + raw = data.get(raw_key) + if raw is None or raw == "": + return False + if isinstance(raw, list): + return len(raw) > 0 + if isinstance(raw, dict): + return len(raw) > 0 + return True + + +def incident_has_evac_info(geo_event: Dict[str, Any]) -> bool: + """ + True when the incident shows any evacuation-related messaging on Watch Duty. + + Besides ``data.evacuation_orders``, the API often leaves structured lists null and + puts a Genasys (or other) map link in ``data.evacuation_notes`` as HTML or attaches ``evac_zone_statuses`` on the geo_event. + """ + data = event_data(geo_event) + pairs = ( + ("evacuation_orders", "has_custom_evacuation_orders"), + ("evacuation_warnings", "has_custom_evacuation_warnings"), + ("evacuation_advisories", "has_custom_evacuation_advisories"), + ("evacuation_shelter_in_place", "has_custom_evacuation_shelter_in_place"), + ) + for raw_key, has_key in pairs: + if _evac_field_nonempty(data, raw_key, has_key): + return True + notes = strip_html(str(data.get("evacuation_notes") or "")).strip() + if notes: + return True + ez = geo_event.get("evac_zone_statuses") + if isinstance(ez, list) and len(ez) > 0: + return True + return False + + +def has_evacuation_orders_flag(data: Dict[str, Any]) -> bool: + """Backward-compatible: prefer :func:`incident_has_evac_info` with the full geo_event dict.""" + return incident_has_evac_info({"data": data}) + + +def _flatten_evac_item(item: Any) -> str: + if item is None: + return "" + if isinstance(item, str): + return strip_html(item).strip() + if isinstance(item, dict): + for key in ("text", "message", "description", "title", "name", "body", "summary"): + val = item.get(key) + if val is not None and str(val).strip(): + return strip_html(str(val)).strip() + try: + return strip_html(json.dumps(item, ensure_ascii=False)).strip() + except Exception: + return str(item) + return strip_html(str(item)).strip() + + +def _evacuation_lines_for_data_key(data: Dict[str, Any], raw_key: str) -> List[str]: + """Plain-text lines for one ``data`` collection (orders, warnings, etc.).""" + raw = data.get(raw_key) + if raw is None: + return [] + if isinstance(raw, str): + t = strip_html(raw).strip() + return [t] if t else [] + if isinstance(raw, list): + lines: List[str] = [] + for item in raw: + line = _flatten_evac_item(item) + if line: + lines.append(line) + return lines + if isinstance(raw, dict): + line = _flatten_evac_item(raw) + return [line] if line else [] + line = _flatten_evac_item(raw) + return [line] if line else [] + + +def evacuation_order_lines(data: Dict[str, Any]) -> List[str]: + """Lines from ``data.evacuation_orders`` only (see :func:`evacuation_display_lines` for full incident).""" + return _evacuation_lines_for_data_key(data, "evacuation_orders") + + +def evacuation_display_lines(geo_event: Dict[str, Any]) -> List[str]: + """ + All evacuation-related lines for mesh display, in a stable order. + + Watch Duty may use ``evacuation_notes`` (HTML) for third-party maps when structured + ``evacuation_orders`` / ``evacuation_warnings`` are null. + """ + data = event_data(geo_event) + out: List[str] = [] + labeled = ( + ("[Order] ", "evacuation_orders"), + ("[Warn] ", "evacuation_warnings"), + ("[Advisory] ", "evacuation_advisories"), + ("[Shelter] ", "evacuation_shelter_in_place"), + ) + for prefix, key in labeled: + for line in _evacuation_lines_for_data_key(data, key): + out.append(prefix + line) + notes = strip_html(str(data.get("evacuation_notes") or "")).strip() + if notes: + out.append("[Note] " + notes) + ez = geo_event.get("evac_zone_statuses") + if isinstance(ez, list): + for z in ez: + zline = _flatten_evac_item(z) + if zline: + out.append("[Zone] " + zline) + return out + + +def evacuation_display_count(geo_event: Dict[str, Any]) -> int: + """Number of evacuation-related items (orders, warnings, notes, zones, etc.); matches ``evac`` output rows.""" + return len(evacuation_display_lines(geo_event)) + + +def resolve_active_event_by_query( + events: List[Dict[str, Any]], + query: str, + *, + config: Optional[Any] = None, + include_prescribed: bool = False, +) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + """ + Match a fire from the ``fires`` list or by Watch Duty geo_event id / name. + + Numeric ``query`` resolution order: + + 1. **Id in list** โ€” any loaded event with ``id == n`` (same id as ``https://app.watchduty.org/i/``). + 2. **List index** โ€” ``n`` from 1 โ€ฆ ``len(events)`` (same order as ``fires``). + 3. **Fetch by id** โ€” when ``config`` is passed, ``GET /geo_events/``; must be active; + (bbox is not applied so a direct id still resolves outside the optional Watch Duty bbox filter). + """ + t = (query or "").strip() + if not t: + return None, "usage" + if t.isdigit(): + n = int(t) + for e in events: + eid = e.get("id") + if eid is not None: + try: + if int(eid) == n: + return e, None + except (TypeError, ValueError): + continue + if 1 <= n <= len(events): + return events[n - 1], None + if config is not None: + detail = fetch_event_detail(n) + if not detail: + return None, ( + f"No active fire id {n}. " + f"Use fires for #1โ€“{len(events)} or an id from app.watchduty.org/i/" + ) + if not geo_event_is_active(detail): + return None, f"Fire id {n} is not active." + if not include_prescribed and (detail.get("data") or {}).get("is_prescribed"): + return None, ( + f"Fire id {n} is a prescribed burn." + ) + return detail, None + return None, f"No fire #{n} ({len(events)} active). Try fires." + t_lower = t.lower() + for e in events: + name = (e.get("name") or "").strip() + if name.lower() == t_lower: + return e, None + matches = [e for e in events if t_lower in (e.get("name") or "").strip().lower()] + if len(matches) == 1: + return matches[0], None + if not matches: + return None, f"No active fire matching '{t}'." + labels = [((m.get("name") or "?")[:48]) for m in matches[:5]] + extra = f" (+{len(matches) - 5} more)" if len(matches) > 5 else "" + return None, "Multiple matches: " + "; ".join(labels) + extra + + +def mesh_pack_lines(lines: List[str], max_len: int) -> List[str]: + """Pack non-empty lines into newline-separated chunks no longer than max_len.""" + out: List[str] = [] + buf = "" + for line in lines: + if not line: + continue + if not buf: + candidate = line + else: + candidate = buf + "\n" + line + if len(candidate) <= max_len: + buf = candidate + else: + if buf: + out.append(buf) + if len(line) <= max_len: + buf = line + else: + start = 0 + while start < len(line): + out.append(line[start : start + max_len]) + start += max_len + buf = "" + if buf: + out.append(buf) + return out if out else [""] + + +def format_report_message( + event_name: str, + report_message_plain: str, + event_id: Optional[int] = None, + max_len: int = 136, +) -> str: + """ + Prefix report with event name; truncate to max_len. + + If event_id is set, always keeps the incident URL intact at the end of the line, + trimming the report text portion as needed to fit within max_len. + """ + max_name = 40 + name = (event_name[: max_name - 3] + "...") if len(event_name) > max_name else event_name + prefix = f"{name}: " + if event_id is None: + remainder = max_len - len(prefix) + if remainder <= 0: + return prefix[: max_len] + text = report_message_plain + if len(text) > remainder: + text = text[: remainder - 3].rstrip() + "..." + return prefix + text + + link = incident_url(event_id) + suffix = f" | {link}" + # If even the suffix alone is too long, fall back to raw link truncated + if len(suffix) > max_len: + return (link[: max_len - 3].rstrip() + "...") if len(link) > max_len else link + + available = max_len - len(suffix) + if available <= 0: + return link + + # Ensure prefix fits inside available space and trim text accordingly + if len(prefix) >= available: + # Prefix alone fills available space; trim prefix + base = prefix[: available - 3].rstrip() + "..." + else: + remainder = available - len(prefix) + text = report_message_plain + if len(text) > remainder: + text = text[: remainder - 3].rstrip() + "..." + base = prefix + text + + return f"{base}{suffix}" diff --git a/tests/test_db_migrations.py b/tests/test_db_migrations.py index 70ee962d..7d0a2ad1 100644 --- a/tests/test_db_migrations.py +++ b/tests/test_db_migrations.py @@ -292,6 +292,19 @@ def test_purging_log_has_details_column_after_full_migration(self, runner, conn) cursor = conn.cursor() assert _column_exists(cursor, "purging_log", "details") is True + def test_watchduty_tables_created_by_migrations(self, runner, conn): + runner.run() + for table in [ + "watchduty_sent_reports", + "watchduty_feed_state", + "watchduty_alert_suppression", + ]: + cur = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + (table,), + ) + assert cur.fetchone() is not None + def test_migration_adds_purging_log_details_for_legacy_schema(self, conn, logger): conn.execute(""" CREATE TABLE IF NOT EXISTS schema_version ( From 3ae68fe169dfd7a01543297e564f77ca6ee0a66b Mon Sep 17 00:00:00 2001 From: chrisdavis2110 Date: Wed, 6 May 2026 22:23:01 -0700 Subject: [PATCH 2/3] updated evac no argument --- modules/commands/evac_command.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/modules/commands/evac_command.py b/modules/commands/evac_command.py index f6db386e..944e78e3 100644 --- a/modules/commands/evac_command.py +++ b/modules/commands/evac_command.py @@ -67,13 +67,6 @@ def _parse_event_and_item_query(tail: str) -> Tuple[str, Optional[int]]: async def execute(self, message: MeshMessage) -> bool: tail = self._args_tail(message) - event_query, item_n = self._parse_event_and_item_query(tail) - if not event_query: - return await self.send_response( - message, - "Usage: evac [item #] โ€” same list as fires; id from app.watchduty.org/i/", - ) - try: events = watchduty_poll.fetch_active_geo_events_for_user_query( self.bot.config, @@ -83,6 +76,28 @@ async def execute(self, message: MeshMessage) -> bool: self.logger.error("evac command: fetch failed: %s", e) return await self.send_response(message, "Could not load fires (Watch Duty).") + event_query, item_n = self._parse_event_and_item_query(tail) + if not event_query: + evac_events = [e for e in events if watchduty_poll.incident_has_evac_info(e)] + max_len = self.get_max_message_length(message) + if not evac_events: + return await self.send_response( + message, + "No active fires with evacuation info right now.", + ) + lines: List[str] = [f"Fires with evacuations ({len(evac_events)}):"] + for i, event in enumerate(evac_events, start=1): + name = (event.get("name") or f"Event {event.get('id')}").strip() + loc = watchduty_poll.format_location_short(event) + eid = event.get("id") + id_part = f" ยท {eid}" if eid is not None else "" + lines.append(f"{i}. {name} ({loc}){id_part}") + lines.append("Use: evac for snippets; evac <#> for full text.") + chunks = watchduty_poll.mesh_pack_lines(lines, max_len) + if len(chunks) == 1: + return await self.send_response(message, chunks[0]) + return await self.send_response_chunked(message, chunks) + event, err = watchduty_poll.resolve_active_event_by_query( events, event_query, From 73d1e3940f023a675a263a4e6340b59fbf29140c Mon Sep 17 00:00:00 2001 From: chrisdavis2110 Date: Wed, 13 May 2026 20:57:29 -0700 Subject: [PATCH 3/3] updated evac command to only show orders and warnings --- modules/commands/evac_command.py | 19 +++++---- modules/watchduty_poll.py | 72 +++++++++++++------------------- 2 files changed, 41 insertions(+), 50 deletions(-) diff --git a/modules/commands/evac_command.py b/modules/commands/evac_command.py index 944e78e3..413f26f6 100644 --- a/modules/commands/evac_command.py +++ b/modules/commands/evac_command.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""List Watch Duty evacuation orders for a fire.""" +"""List Watch Duty evacuation orders and warnings for a fire.""" from typing import List, Optional, Tuple @@ -11,13 +11,16 @@ class EvacCommand(BaseCommand): name = "evac" keywords = ["evac", "evacs"] - description = "List evacuation info (usage: evac [item #])" + description = ( + "List evacuation orders and warnings " + "(usage: evac [<# from evac list|Watch Duty id|name>] [item #])" + ) category = "info" requires_internet = True cooldown_seconds = 5 - short_description = "Show Watch Duty evacuation orders, warnings, notes, and zone status lines" - usage = "evac [item #]" + short_description = "Show Watch Duty evacuation orders and warnings" + usage = "evac [<# from evac list|Watch Duty id|name>] [item #]" examples = ["evac Woods Fire", "evac 1", "evac 93683", "evac 93817 2"] def __init__(self, bot): @@ -76,9 +79,9 @@ async def execute(self, message: MeshMessage) -> bool: self.logger.error("evac command: fetch failed: %s", e) return await self.send_response(message, "Could not load fires (Watch Duty).") + evac_events = [e for e in events if watchduty_poll.incident_has_evac_info(e)] event_query, item_n = self._parse_event_and_item_query(tail) if not event_query: - evac_events = [e for e in events if watchduty_poll.incident_has_evac_info(e)] max_len = self.get_max_message_length(message) if not evac_events: return await self.send_response( @@ -92,7 +95,6 @@ async def execute(self, message: MeshMessage) -> bool: eid = event.get("id") id_part = f" ยท {eid}" if eid is not None else "" lines.append(f"{i}. {name} ({loc}){id_part}") - lines.append("Use: evac for snippets; evac <#> for full text.") chunks = watchduty_poll.mesh_pack_lines(lines, max_len) if len(chunks) == 1: return await self.send_response(message, chunks[0]) @@ -103,12 +105,14 @@ async def execute(self, message: MeshMessage) -> bool: event_query, config=self.bot.config, include_prescribed=self._include_prescribed, + numeric_index_list=evac_events, ) if err: if err == "usage": return await self.send_response( message, - "Usage: evac [item #] โ€” same list as fires; id from app.watchduty.org/i/", + "Usage: evac [<# from evac list|Watch Duty id|name>] [item #] โ€” " + "list #s match evac with no args, not fires.", ) return await self.send_response(message, err) @@ -145,7 +149,6 @@ async def execute(self, message: MeshMessage) -> bool: if snippet != line and len(snippet) < len(line): snippet = snippet.rstrip() + " [...]" lines.append(f"{i}. {snippet}") - lines.append("Use: evac <#> for full text.") chunks = watchduty_poll.mesh_pack_lines(lines, max_len) if len(chunks) == 1: diff --git a/modules/watchduty_poll.py b/modules/watchduty_poll.py index 55ab6236..2fc8412b 100644 --- a/modules/watchduty_poll.py +++ b/modules/watchduty_poll.py @@ -356,16 +356,18 @@ def fetch_active_geo_events_for_user_query( def format_location_short(event: Dict[str, Any], max_len: int = 56) -> str: """ - Short location for compact lists, e.g. last two comma-separated parts of address - (often city, state), else format_location truncated. + Short location for compact lists: last comma-separated segment of ``address`` (city name), + else ``format_location`` truncated. """ addr = (event.get("address") or "").strip() if addr and "," in addr: parts = [p.strip() for p in addr.split(",") if p.strip()] - if len(parts) >= 2: - tail = ", ".join(parts[-2:]) - if len(tail) <= max_len: - return tail + if parts: + tail = parts[-1] + if tail: + if len(tail) <= max_len: + return tail + return tail[: max_len - 3].rstrip() + "..." loc = format_location(event) if len(loc) > max_len: return loc[: max_len - 3].rstrip() + "..." @@ -392,28 +394,15 @@ def _evac_field_nonempty(data: Dict[str, Any], raw_key: str, has_custom_key: str def incident_has_evac_info(geo_event: Dict[str, Any]) -> bool: - """ - True when the incident shows any evacuation-related messaging on Watch Duty. - - Besides ``data.evacuation_orders``, the API often leaves structured lists null and - puts a Genasys (or other) map link in ``data.evacuation_notes`` as HTML or attaches ``evac_zone_statuses`` on the geo_event. - """ + """True when the incident has structured evacuation orders or warnings on Watch Duty.""" data = event_data(geo_event) pairs = ( ("evacuation_orders", "has_custom_evacuation_orders"), ("evacuation_warnings", "has_custom_evacuation_warnings"), - ("evacuation_advisories", "has_custom_evacuation_advisories"), - ("evacuation_shelter_in_place", "has_custom_evacuation_shelter_in_place"), ) for raw_key, has_key in pairs: if _evac_field_nonempty(data, raw_key, has_key): return True - notes = strip_html(str(data.get("evacuation_notes") or "")).strip() - if notes: - return True - ez = geo_event.get("evac_zone_statuses") - if isinstance(ez, list) and len(ez) > 0: - return True return False @@ -462,42 +451,26 @@ def _evacuation_lines_for_data_key(data: Dict[str, Any], raw_key: str) -> List[s def evacuation_order_lines(data: Dict[str, Any]) -> List[str]: - """Lines from ``data.evacuation_orders`` only (see :func:`evacuation_display_lines` for full incident).""" + """Lines from ``data.evacuation_orders`` only (see :func:`evacuation_display_lines` for orders and warnings).""" return _evacuation_lines_for_data_key(data, "evacuation_orders") def evacuation_display_lines(geo_event: Dict[str, Any]) -> List[str]: - """ - All evacuation-related lines for mesh display, in a stable order. - - Watch Duty may use ``evacuation_notes`` (HTML) for third-party maps when structured - ``evacuation_orders`` / ``evacuation_warnings`` are null. - """ + """Evacuation orders and warnings only (mesh display), in that order.""" data = event_data(geo_event) out: List[str] = [] labeled = ( ("[Order] ", "evacuation_orders"), ("[Warn] ", "evacuation_warnings"), - ("[Advisory] ", "evacuation_advisories"), - ("[Shelter] ", "evacuation_shelter_in_place"), ) for prefix, key in labeled: for line in _evacuation_lines_for_data_key(data, key): out.append(prefix + line) - notes = strip_html(str(data.get("evacuation_notes") or "")).strip() - if notes: - out.append("[Note] " + notes) - ez = geo_event.get("evac_zone_statuses") - if isinstance(ez, list): - for z in ez: - zline = _flatten_evac_item(z) - if zline: - out.append("[Zone] " + zline) return out def evacuation_display_count(geo_event: Dict[str, Any]) -> int: - """Number of evacuation-related items (orders, warnings, notes, zones, etc.); matches ``evac`` output rows.""" + """Number of evacuation order and warning lines for mesh display.""" return len(evacuation_display_lines(geo_event)) @@ -507,14 +480,16 @@ def resolve_active_event_by_query( *, config: Optional[Any] = None, include_prescribed: bool = False, + numeric_index_list: Optional[List[Dict[str, Any]]] = None, ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: """ Match a fire from the ``fires`` list or by Watch Duty geo_event id / name. Numeric ``query`` resolution order: - 1. **Id in list** โ€” any loaded event with ``id == n`` (same id as ``https://app.watchduty.org/i/``). - 2. **List index** โ€” ``n`` from 1 โ€ฆ ``len(events)`` (same order as ``fires``). + 1. **Id in list** โ€” any loaded event in ``events`` with ``id == n`` (same id as ``https://app.watchduty.org/i/``). + 2. **List index** โ€” if ``numeric_index_list`` is set, ``n`` from 1 โ€ฆ ``len(numeric_index_list)`` + (e.g. evac's "fires with evacuations" list); otherwise ``n`` from 1 โ€ฆ ``len(events)`` (fires order). 3. **Fetch by id** โ€” when ``config`` is passed, ``GET /geo_events/``; must be active; (bbox is not applied so a direct id still resolves outside the optional Watch Duty bbox filter). """ @@ -531,11 +506,19 @@ def resolve_active_event_by_query( return e, None except (TypeError, ValueError): continue - if 1 <= n <= len(events): + if numeric_index_list is not None: + if 1 <= n <= len(numeric_index_list): + return numeric_index_list[n - 1], None + elif 1 <= n <= len(events): return events[n - 1], None if config is not None: detail = fetch_event_detail(n) if not detail: + if numeric_index_list is not None: + return None, ( + f"No active fire id {n}. " + f"Use evac for #1โ€“{len(numeric_index_list)} or an id from app.watchduty.org/i/" + ) return None, ( f"No active fire id {n}. " f"Use fires for #1โ€“{len(events)} or an id from app.watchduty.org/i/" @@ -547,6 +530,11 @@ def resolve_active_event_by_query( f"Fire id {n} is a prescribed burn." ) return detail, None + if numeric_index_list is not None: + return None, ( + f"No evacuation list #{n} ({len(numeric_index_list)} with evacuations). " + "Run evac (no args) for the list." + ) return None, f"No fire #{n} ({len(events)} active). Try fires." t_lower = t.lower() for e in events: