diff --git a/.env.example b/.env.example index 480bca4..6c67dba 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,10 @@ DEBUG=True or False COMRADELY_CONTACT=signal group ID to send the message to COMRADELY_MESSAGE=contents of the message to send COMRADELY_TIME=time to send the message, needs to be in ISO format and UTC timezone (e.g. 20:00:00) +OPENMHZ_URL=URL of the openmhz endpoint you are using to pull call data down from e.g. https://api.openmhz.com/kcers1b/calls/newer?filter-type=group&filter-code=5ee350e04983d0002586456f +RADIO_CHASER_URL=URL of the radio-chaser endpoint you are using to pull call data down from e.g. https://radio-chaser.tech-bloc-sea.dev/radios/get-verbose +RADIO_MONITOR_UNITS=CSV of units to be looking for on radio IDs, case insensitive. e.g. CRG,Community Response Group,SWAT +RADIO_MONITOR_CONTACT=signal group ID to send the message to +RADIO_MONITOR_LOOKBACK=Basically the check interval for looking for new calls from openmhz. Time value is in seconds and must be at least 45 or greater. If not set or less than 45 the interval will be set for 45 seconds. +RADIO_AUDIO_CHUNK_SIZE=How many bytes to read when downloading an audio file from OpenMhz. Defaults to 10 bytes. Probably shouldn't be changed. +DEFAULT_TZ=TZ database formatted name for a timezone. Defaults to US/Pacific diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 960d298..4d976da 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -94,3 +94,6 @@ repos: additional_dependencies: - types-click - types-ujson + - types-requests + - types-pytz + - types-aiofiles diff --git a/requirements.txt b/requirements.txt index c9c2f1e..57976d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,10 @@ +aiofiles>=0.7.0 +aiohttp>=3.7.4 Click==7.1.2 peony-twitter==2.0.2 pre-commit==2.9.0 +pytz>=2021.3 +requests==2.26.0 tweepy==3.9.0 ujson==3.1.0 urllib3==1.26.5 diff --git a/signal_scanner_bot/bin/run.py b/signal_scanner_bot/bin/run.py index 1eadaf2..2d55c63 100644 --- a/signal_scanner_bot/bin/run.py +++ b/signal_scanner_bot/bin/run.py @@ -8,6 +8,7 @@ from signal_scanner_bot.transport import ( comradely_reminder, queue_to_signal, + radio_monitor_alert_transport, signal_to_twitter, twitter_to_queue, ) @@ -44,6 +45,7 @@ def cli(debug: bool = False) -> None: queue_to_signal(), twitter_to_queue(), comradely_reminder(), + radio_monitor_alert_transport(), return_exceptions=True, ) ) diff --git a/signal_scanner_bot/env.py b/signal_scanner_bot/env.py index cf23be1..3b249f9 100644 --- a/signal_scanner_bot/env.py +++ b/signal_scanner_bot/env.py @@ -1,11 +1,12 @@ import logging import os from asyncio import Queue -from datetime import time +from datetime import time, tzinfo from pathlib import Path from typing import Any, Callable, List, Optional, Set import peony +import pytz log = logging.getLogger(__name__) @@ -128,6 +129,10 @@ def _cast_to_time(to_cast: str) -> time: return time.fromisoformat(to_cast) +def _cast_to_tzinfo(to_cast: str) -> tzinfo: + return pytz.timezone(to_cast) + + def _format_hashtags(to_cast: str) -> List[str]: hashtags = _cast_to_list(to_cast) if any("#" in hashtag for hashtag in hashtags): @@ -141,13 +146,15 @@ def _format_hashtags(to_cast: str) -> List[str]: ################################################################################ -# Environment Variables +# Scanner Environment Variables ################################################################################ # Because sometimes I get zero width unicode characters in my copy/pastes that # I don't notice I'm doing a bit of an "inelegant" fix to make sure it doesn't # matter. BOT_NUMBER = _env("BOT_NUMBER", convert=_cast_to_ascii) - +DEFAULT_TZ = _env( + "DEFAULT_TZ", convert=_cast_to_tzinfo, fail=False, default="US/Pacific" +) TESTING = _env("TESTING", convert=_cast_to_bool, default=False) DEBUG = TESTING or _env("DEBUG", convert=_cast_to_bool, default=False) ADMIN_CONTACT = _env("ADMIN_CONTACT", convert=_cast_to_string) @@ -168,6 +175,10 @@ def _format_hashtags(to_cast: str) -> List[str]: convert=_cast_to_path, default="signal_scanner_bot/.autoscanner-state-file", ) + +################################################################################ +# Comradely Reminder Environment Variables +################################################################################ COMRADELY_CONTACT = _env("COMRADELY_CONTACT", convert=_cast_to_string, fail=False) COMRADELY_MESSAGE = _env("COMRADELY_MESSAGE", convert=_cast_to_string, fail=False) COMRADELY_TIME = _env( @@ -177,6 +188,30 @@ def _format_hashtags(to_cast: str) -> List[str]: default="20:00:00", # 2pm PST ) +################################################################################ +# SWAT Alert Environment Variables +################################################################################ +OPENMHZ_URL = _env("OPENMHZ_URL", convert=_cast_to_string, fail=False) +RADIO_CHASER_URL = _env("RADIO_CHASER_URL", convert=_cast_to_string, fail=False) +RADIO_MONITOR_UNITS = _env("RADIO_MONITOR_UNITS", convert=_cast_to_set, fail=False) +RADIO_MONITOR_CONTACT = _env( + "RADIO_MONITOR_CONTACT", convert=_cast_to_string, fail=False +) +RADIO_MONITOR_LOOKBACK = _env( + "RADIO_MONITOR_LOOKBACK", convert=_cast_to_int, fail=False, default=45 +) +RADIO_AUDIO_CHUNK_SIZE = _env( + "RADIO_AUDIO_CHUNK_SIZE", convert=_cast_to_int, fail=False, default=10 +) + +# Check to make sure the lookback interval is greater than or equal to 45 seconds +if RADIO_MONITOR_LOOKBACK < 45: + log.warning( + f"The minimum value for the lookback time is 45 seconds. Time of {RADIO_MONITOR_LOOKBACK}" + " second(s) is less than 45 seconds and will be set to 45 seconds automatically." + ) + RADIO_MONITOR_LOOKBACK = 45 + # Checking to ensure user ids are in the proper format, raise error if not. for tweeter in TRUSTED_TWEETERS: if tweeter[0] == "@": diff --git a/signal_scanner_bot/messages.py b/signal_scanner_bot/messages.py index 36d4aa6..32110ee 100644 --- a/signal_scanner_bot/messages.py +++ b/signal_scanner_bot/messages.py @@ -1,8 +1,11 @@ import logging +import pathlib import re from textwrap import dedent from typing import Callable, Dict, List, TypeVar +import aiofiles +import aiohttp import peony from . import env, signal, twitter @@ -159,3 +162,28 @@ async def send_comradely_reminder() -> None: return log.info("Sending comradely message") signal.send_message(env.COMRADELY_MESSAGE, env.COMRADELY_CONTACT) + + +async def send_radio_monitor_alert(message: str, audio_url: str) -> None: + """Send a SWAT alert.""" + if not env.RADIO_MONITOR_CONTACT: + return + log.info("Sending SWAT alert") + pathlib.Path("/audio").mkdir(exist_ok=True) + local_path_file = pathlib.Path("/audio/" + audio_url.split("/")[-1]) + log.debug(f"Saving audio file to {local_path_file}") + async with aiohttp.ClientSession() as session: + async with session.get(audio_url) as response: + async with aiofiles.open(local_path_file, "wb") as file_download: + while True: + chunk = await response.content.read(env.RADIO_AUDIO_CHUNK_SIZE) + if not chunk: + break + await file_download.write(chunk) + log.debug("File successfully downloaded!") + + signal.send_message(message, env.RADIO_MONITOR_CONTACT, attachment=local_path_file) + + log.debug(f"Deleting audio file at {local_path_file}") + local_path_file.unlink(missing_ok=True) + log.debug("File successfully deleted!") diff --git a/signal_scanner_bot/radio_monitor_alert.py b/signal_scanner_bot/radio_monitor_alert.py new file mode 100644 index 0000000..5037987 --- /dev/null +++ b/signal_scanner_bot/radio_monitor_alert.py @@ -0,0 +1,85 @@ +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple + +import pytz +import requests + +from . import env + + +log = logging.getLogger(__name__) + + +def _convert_to_timestr(in_time: str) -> str: + # Convert time string to datetime after converting it into proper ISO format + # Add timezone awareness (source is in UTC) then output in specified TZ and + # 12 hour format + time_dt = datetime.fromisoformat(in_time.replace("Z", "+00:00")) + time_dt_tz = time_dt.replace(tzinfo=pytz.utc) + return time_dt_tz.astimezone(env.DEFAULT_TZ).strftime("%Y-%m-%d, %I:%M:%S %Z") + + +def _calculate_lookback_time() -> str: + # Because time.timestamp() gives us time in the format 1633987202.136147 and + # we want it in the format 1633987202136 we need to do some str manipulation. + # First we get the current time in UTC, subtract a time delta equal to the + # specified number of seconds defined in the RADIO_MONITOR_LOOKBACK, split + # that timestamp on the decimal, then rejoin the str with the first three + # numbers after the decimal. + time = datetime.now(pytz.utc) - timedelta(seconds=(env.RADIO_MONITOR_LOOKBACK)) + time_stamp_array = str(time.timestamp()).split(".") + return time_stamp_array[0] + time_stamp_array[1][:3] + + +def get_openmhz_calls() -> Dict: + lookback_time = _calculate_lookback_time() + log.debug(f"Lookback is currently set to: {lookback_time}") + response = requests.get(env.OPENMHZ_URL, params={"time": lookback_time}) + return response.json()["calls"] + + +def get_pigs(calls: Dict) -> List[Tuple[Dict, str, str]]: + interesting_pigs = [] + for call in calls: + time = call["time"] + radios = radios = {f"7{radio['src']:0>5}" for radio in call["srcList"]} + if not len(radios): + continue + cops = requests.get(env.RADIO_CHASER_URL, params={"radio": radios}) + log.debug(f"URL requested: {cops.url}") + log.debug(f"List of cops returned by radio-chaser:\n{cops.json()}") + for cop in cops.json().values(): + if all( + unit.lower() not in cop["unit_description"].lower() + for unit in env.RADIO_MONITOR_UNITS + ): + log.debug(f"{cop}\nUnit not found in list of monitored units.") + continue + log.debug(f"{cop}\nUnit found in list of monitored units.") + time_formatted_in_tz = _convert_to_timestr(time) + interesting_pigs.append((cop, time_formatted_in_tz, call["url"])) + return interesting_pigs + + +def format_pigs(pigs: List[Tuple[Dict, str, str]]) -> List[Tuple[str, str]]: + formatted_pigs = [] + for cop, time, url in pigs: + name, badge, unit_description, time = ( + cop["full_name"], + cop["badge"], + cop["unit_description"], + time, + ) + formatted_pigs.append((f"{name}\n{badge}\n{unit_description}\n{time}", url)) + return formatted_pigs + + +def check_radio_calls() -> Optional[List[Tuple[str, str]]]: + calls = get_openmhz_calls() + log.debug(f"Calls from OpenMHz:\n{calls}") + pigs = get_pigs(calls) + if not pigs: + return None + log.debug(f"Interesting pigs found\n{pigs}") + return format_pigs(pigs) diff --git a/signal_scanner_bot/signal.py b/signal_scanner_bot/signal.py index c96edd6..7295326 100644 --- a/signal_scanner_bot/signal.py +++ b/signal_scanner_bot/signal.py @@ -81,11 +81,13 @@ def trust_identity(phone_number: str, safety_number: str): log.error(f"Trust call return code: {proc.returncode}") -def send_message(message: str, recipient: str): +def send_message(message: str, recipient: str, attachment=None): """High level function to send a Signal message to a specified recipient.""" group = _check_group(recipient) recipient_args = ["-g", recipient] if group else [recipient] + attachement_args = ["-a", attachment] if attachment else [] + log.debug("Sending message") proc = subprocess.run( [ @@ -96,6 +98,7 @@ def send_message(message: str, recipient: str): "-m", message, *recipient_args, + *attachement_args, ], capture_output=True, text=True, diff --git a/signal_scanner_bot/transport.py b/signal_scanner_bot/transport.py index 866c8fd..504b3f7 100644 --- a/signal_scanner_bot/transport.py +++ b/signal_scanner_bot/transport.py @@ -7,7 +7,7 @@ import ujson from peony import events -from . import env, messages, signal +from . import env, messages, radio_monitor_alert, signal log = logging.getLogger(__name__) @@ -133,3 +133,32 @@ async def comradely_reminder() -> None: log.exception(err) signal.panic(err) raise + + +################################################################################ +# SWAT Alert +################################################################################ +async def radio_monitor_alert_transport() -> None: + """Run the radio monitor alert loop.""" + # Wait for system to initialize + await asyncio.sleep(15) + while True: + try: + log.debug("Checking for monitored units' radio activity.") + if radio_monitor_alert_messages := radio_monitor_alert.check_radio_calls(): + log.info( + "Radio activity found for monitored units sending alert to group." + ) + log.debug(f"Monitored units are {env.RADIO_MONITOR_UNITS}") + log.debug(f"Alert messages to be sent:\n{radio_monitor_alert_messages}") + for message, audio in radio_monitor_alert_messages: + await messages.send_radio_monitor_alert(message, audio) + # Wait a minute to poll again + log.debug( + f"Sleeping for {env.RADIO_MONITOR_LOOKBACK}s before checking for monitored unit alerts again." + ) + await asyncio.sleep(env.RADIO_MONITOR_LOOKBACK) + except Exception as err: + log.exception(err) + signal.panic(err) + raise