Skip to content

Commit

Permalink
SWAT Alerts (#75)
Browse files Browse the repository at this point in the history
  • Loading branch information
mpuckett159 committed Oct 12, 2021
1 parent acda98e commit 166ef75
Show file tree
Hide file tree
Showing 9 changed files with 201 additions and 5 deletions.
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,6 @@ repos:
additional_dependencies:
- types-click
- types-ujson
- types-requests
- types-pytz
- types-aiofiles
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions signal_scanner_bot/bin/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
)
Expand Down
41 changes: 38 additions & 3 deletions signal_scanner_bot/env.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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] == "@":
Expand Down
28 changes: 28 additions & 0 deletions signal_scanner_bot/messages.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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!")
85 changes: 85 additions & 0 deletions signal_scanner_bot/radio_monitor_alert.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 4 additions & 1 deletion signal_scanner_bot/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand All @@ -96,6 +98,7 @@ def send_message(message: str, recipient: str):
"-m",
message,
*recipient_args,
*attachement_args,
],
capture_output=True,
text=True,
Expand Down
31 changes: 30 additions & 1 deletion signal_scanner_bot/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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

0 comments on commit 166ef75

Please sign in to comment.