Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 92 additions & 49 deletions config.ini.example

Large diffs are not rendered by default.

156 changes: 156 additions & 0 deletions modules/commands/evac_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""List Watch Duty evacuation orders and warnings 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 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 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):
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 <event>`` and ``evac <event> <item #>``.
"""
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)
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).")

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:
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}")
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,
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 [<# from evac list|Watch Duty id|name>] [item #] — "
"list #s match evac with no args, not fires.",
)
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}")

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)
116 changes: 116 additions & 0 deletions modules/commands/fire_command.py
Original file line number Diff line number Diff line change
@@ -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/<id>.",
)
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)
1 change: 1 addition & 0 deletions modules/config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions modules/db_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
32 changes: 32 additions & 0 deletions modules/db_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
# ---------------------------------------------------------------------------
Expand All @@ -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),
]


Expand Down
Loading