seattlecitylight-mastodon-bot/scl.py

507 lines
20 KiB
Python
Raw Normal View History

2024-01-13 17:13:38 -08:00
import io
2024-01-13 14:24:00 -08:00
from datetime import datetime
2024-01-13 15:01:11 -08:00
from typing import Optional
2024-01-31 21:34:58 -08:00
import mastodon
2024-01-13 14:24:00 -08:00
import requests
import shapely
2024-01-14 12:27:02 -08:00
import yaml
2024-01-13 14:24:00 -08:00
from mastodon import Mastodon
2024-01-13 17:13:38 -08:00
from PIL import Image, ImageDraw, ImageFont
2024-01-13 15:01:11 -08:00
from sqlalchemy import create_engine, select
2024-01-13 14:24:00 -08:00
from sqlalchemy.exc import NoResultFound
2024-01-13 15:01:11 -08:00
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
2024-08-25 11:58:41 -07:00
from staticmap import Polygon, StaticMap
from geospatial import convert_outage_geometry
2024-01-13 14:24:00 -08:00
post_datetime_format = "%b %e %l:%M %p"
scl_events_url = "https://utilisocial.io/datacapable/v2/p/scl/map/events"
scl_events_response = requests.get(scl_events_url)
try:
scl_events = scl_events_response.json()
except requests.JSONDecodeError:
print("JSON could not be loaded from SCL API")
raise
2024-01-14 12:27:02 -08:00
config = yaml.safe_load(open("config.yml"))
stadiamaps_api_key = config["stadiamaps"]["api_key"]
nominatim_url = config["nominatim"]["api_base_url"]
2024-01-31 21:34:58 -08:00
mastodon_client = Mastodon(
2024-01-14 12:27:02 -08:00
client_id=config["mastodon"]["client_id"],
client_secret=config["mastodon"]["client_secret"],
access_token=config["mastodon"]["access_token"],
api_base_url=config["mastodon"]["api_base_url"],
)
2024-01-13 17:13:38 -08:00
class AttribStaticMap(StaticMap, object):
def __init__(self, *args, **kwargs):
self.attribution = "© Stadia Maps © OpenMapTiles © OpenStreetMap"
super(AttribStaticMap, self).__init__(*args, **kwargs)
def _draw_features(self, image):
super(AttribStaticMap, self)._draw_features(image)
txt = Image.new("RGBA", image.size, (255, 255, 255, 0))
2024-08-25 11:58:41 -07:00
fnt = ImageFont.truetype("fonts/PublicSans-Regular.otf", 24)
2024-01-13 17:13:38 -08:00
d = ImageDraw.Draw(txt)
textSize = fnt.getbbox(self.attribution)
textPosition = (image.size[0] - textSize[2], image.size[1] - textSize[3])
offset = 2
options = {"fill": (255, 255, 255, 180)}
d.rectangle(
[
(textPosition[0] - (2 * offset), textPosition[1] - (2 * offset)),
(
textSize[2] + textPosition[0] + (2 * offset),
textSize[3] + textPosition[1] + (2 * offset),
),
],
**options,
)
# draw text, full opacity
d.text(
(textPosition[0] - offset, textPosition[1] - offset),
self.attribution,
font=fnt,
fill="black",
)
image.paste(txt, (0, 0), txt)
2024-01-13 14:24:00 -08:00
2024-02-15 07:53:17 -08:00
def classify_event_size(num_people: int) -> dict[str, str | bool]:
2024-01-30 07:58:11 -08:00
if num_people < 250:
return {
"size": "Small",
"outage_color": "#F97316",
"is_postable": False,
}
2024-02-13 20:42:02 -08:00
elif num_people < 1000:
2024-01-30 07:58:11 -08:00
return {
"size": "Medium",
"outage_color": "#EF4444",
"is_postable": True,
}
else:
return {
"size": "Large",
"outage_color": "#991B1B",
"is_postable": True,
}
2024-02-15 07:53:17 -08:00
def get_hashtag_string(event) -> str:
city = str()
try:
city = event["geoloc_city"]
except KeyError:
city = event["city"]
neighborhood_text = str()
try:
neighborhood = event["neighborhood"]
if neighborhood != city:
neighborhood_text = " #{}".format(neighborhood.title().replace(" ", ""))
except KeyError:
pass
hashtag_string = "#SeattleCityLightOutage #SCLOutage{} #{}".format(
neighborhood_text, city.title().replace(" ", "")
2024-02-15 07:53:17 -08:00
)
return hashtag_string
def do_initial_post(
event,
event_class,
start_time: datetime,
estimated_restoration_time: datetime,
outage_geometries: shapely.MultiPolygon,
2024-02-15 07:53:17 -08:00
) -> dict[str, str | None]:
post_id = None
map_media_post_id = None
# Fallback location from the SCL API in case one couldn't be reverse geocoded
area_text = event["city"]
try:
map = AttribStaticMap(
2024-08-25 11:58:41 -07:00
1024,
1024,
url_template="https://tiles.stadiamaps.com/tiles/outdoors/{z}/{x}/{y}@2x.png?api_key="
2024-02-15 07:53:17 -08:00
+ stadiamaps_api_key,
2024-08-25 11:58:41 -07:00
tile_size=512,
2024-02-15 07:53:17 -08:00
)
assert event["polygons"]["type"] == "polygon"
for ring in event["polygons"]["rings"]:
polygon = Polygon(
ring,
# Appending 7F to the fill_color makes it 50% transparent
fill_color="{}7F".format(event_class["outage_color"]),
outline_color=event_class["outage_color"],
simplify=True,
)
map.add_polygon(polygon)
try:
outage_center: shapely.Point = outage_geometries.centroid
2024-02-15 07:53:17 -08:00
assert outage_center.geom_type == "Point"
2024-02-15 07:53:17 -08:00
# Check to make sure the calculated lat and lon are sane enough
# NW Corner
assert outage_center.y < 48 and outage_center.x > -122.6
2024-02-15 07:53:17 -08:00
# SE Corner
assert outage_center.y > 47.2 and outage_center.x < -122
2024-02-15 07:53:17 -08:00
# Zoom level 17 ensures that we won't get any building/POI names, just street names
geocode_url = "{nominatim_url}/reverse?lat={lat}&lon={lon}&format=geocodejson&zoom=17".format(
nominatim_url=nominatim_url,
lat=outage_center.y,
lon=outage_center.x,
2024-02-15 07:53:17 -08:00
)
geocode_headers = {"User-Agent": "seattlecitylight-mastodon-bot"}
geocode_response = requests.get(geocode_url, headers=geocode_headers)
try:
geocode = geocode_response.json()
except requests.JSONDecodeError:
print("JSON could not be loaded from nominatim API")
raise
city = geocode["features"][0]["properties"]["geocoding"]["city"]
street = geocode["features"][0]["properties"]["geocoding"]["name"]
event["geoloc_city"] = city
if city != "Seattle":
city_not_seattle_text = " of {}".format(city)
2024-02-15 07:53:17 -08:00
else:
city_not_seattle_text = ""
if (
"locality" in geocode["features"][0]["properties"]["geocoding"]
and event_class["size"] != "Large"
):
locality = geocode["features"][0]["properties"]["geocoding"]["locality"]
if locality == "Uptown":
locality = "Lower Queen Anne"
alt_text = "A map showing the location of the outage, centered around {} in the {} area{}.".format(
street,
locality,
city_not_seattle_text,
)
area_text = "the {} area{}".format(locality, city_not_seattle_text)
event["neighborhood"] = locality
2024-02-15 07:53:17 -08:00
elif "district" in geocode["features"][0]["properties"]["geocoding"]:
district = geocode["features"][0]["properties"]["geocoding"]["district"]
2024-02-15 07:53:17 -08:00
alt_text = "A map showing the location of the outage, centered around {} in the {} area{}.".format(
street,
district,
2024-02-15 07:53:17 -08:00
city_not_seattle_text,
)
area_text = "the {} area{}".format(
district,
2024-02-15 07:53:17 -08:00
city_not_seattle_text,
)
event["neighborhood"] = district
2024-02-15 07:53:17 -08:00
else:
alt_text = "A map showing the location of the outage, centered around {} in {}.".format(
street,
city,
2024-02-15 07:53:17 -08:00
)
area_text = city
2024-02-15 07:53:17 -08:00
except Exception:
alt_text = "A map showing the location of the outage."
map_image = map.render()
2024-02-15 07:53:17 -08:00
with io.BytesIO() as map_image_file:
2024-08-25 11:58:41 -07:00
map_image.save(map_image_file, format="WebP", method=6)
2024-02-15 07:53:17 -08:00
map_media_post = mastodon_client.media_post(
map_image_file.getvalue(),
mime_type="image/png",
description=alt_text,
)
map_media_post_id = map_media_post["id"]
except Exception as e:
print(e)
print(
"Ran into an issue with generating/uploading the map. Will post without it."
)
map_media_post = None
hashtag_string = get_hashtag_string(event)
est_restoration_post_text = str()
if estimated_restoration_time > datetime.now():
est_restoration_post_text = "\nEst. Restoration: {}\n".format(
estimated_restoration_time.strftime(post_datetime_format)
)
2024-02-15 07:53:17 -08:00
post_text = """Seattle City Light is reporting a {} outage in {}.
Start Date: {}{}
2024-02-15 07:53:17 -08:00
Cause: {}
{}""".format(
event_class["size"].lower(),
area_text,
start_time.strftime(post_datetime_format),
est_restoration_post_text,
2024-02-15 07:53:17 -08:00
event["cause"],
hashtag_string,
)
print(
"Posting the following to Mastodon, with a post length of {}:\n{}".format(
len(post_text), post_text
)
)
post_result = mastodon_client.status_post(
status=post_text,
media_ids=map_media_post,
visibility="public",
language="en",
)
post_id = post_result["id"]
return {"post_id": post_id, "map_media_post_id": map_media_post_id}
2024-01-13 14:24:00 -08:00
class Base(DeclarativeBase):
pass
class SclOutage(Base):
__tablename__ = "scl_outages"
scl_outage_id: Mapped[int] = mapped_column(primary_key=True, unique=True)
outage_user_id: Mapped[str] = mapped_column()
2024-01-13 15:00:05 -08:00
most_recent_post_id: Mapped[Optional[str]] = mapped_column()
2024-01-30 07:19:13 -08:00
initial_post_id: Mapped[Optional[str]] = mapped_column()
map_media_post_id: Mapped[Optional[str]] = mapped_column()
2024-01-13 14:24:00 -08:00
last_updated_time: Mapped[datetime] = mapped_column()
estimated_restoration_time: Mapped[datetime] = mapped_column()
cause: Mapped[str] = mapped_column()
status: Mapped[Optional[str]] = mapped_column()
2024-01-13 15:00:05 -08:00
no_longer_in_response_time: Mapped[Optional[datetime]] = mapped_column()
2024-01-29 19:42:12 -08:00
start_time: Mapped[datetime] = mapped_column()
2024-01-29 19:50:37 -08:00
num_people: Mapped[int] = mapped_column()
2024-01-30 07:58:11 -08:00
max_num_people: Mapped[int] = mapped_column()
neighborhood: Mapped[Optional[str]] = mapped_column()
city: Mapped[Optional[str]] = mapped_column()
2024-01-13 14:24:00 -08:00
def __repr__(self) -> str:
return f"SclOutage(scl_outage_id={self.scl_outage_id!r}, most_recent_post_id={self.most_recent_post_id!r}, initial_post_id={self.initial_post_id!r}, map_media_post_id={self.map_media_post_id!r}, last_updated_time={self.last_updated_time!r}, no_longer_in_response_time={self.no_longer_in_response_time!r}, start_time={self.start_time!r}, num_people={self.num_people!r}, max_num_people={self.max_num_people!r}, neighborhood={self.neighborhood!r}, city={self.city!r})"
2024-01-13 14:24:00 -08:00
2024-01-31 21:36:32 -08:00
engine = create_engine("sqlite:///scl.db")
2024-01-13 14:24:00 -08:00
Base.metadata.create_all(engine)
with Session(engine) as session:
for event in scl_events:
print("Processing outage with Internal ID {}".format(event["id"]))
start_time = datetime.fromtimestamp(event["startTime"] / 1000)
last_updated_time = datetime.fromtimestamp(event["lastUpdatedTime"] / 1000)
estimated_restoration_time = datetime.fromtimestamp(event["etrTime"] / 1000)
lookup_statement = select(SclOutage).where(
SclOutage.scl_outage_id == event["id"]
)
lookup_result = session.scalars(lookup_statement)
2024-01-30 07:58:11 -08:00
event_class = classify_event_size(event["numPeople"])
2024-01-13 14:24:00 -08:00
if "status" in event:
status = event["status"]
else:
status = None
outage_geometries = convert_outage_geometry(event)
2024-01-13 14:24:00 -08:00
try:
2024-02-15 07:53:17 -08:00
hashtag_string = get_hashtag_string(event)
2024-01-13 14:24:00 -08:00
existing_record = lookup_result.one()
updated_properties = []
updated_entries = []
est_restoration_diff_mins = (
abs(
(
estimated_restoration_time
- existing_record.estimated_restoration_time
).total_seconds()
)
/ 60
)
# Only post if estimated restoration time has changed by 60m or more
if est_restoration_diff_mins >= 60:
2024-01-13 14:24:00 -08:00
existing_record.estimated_restoration_time = estimated_restoration_time
if estimated_restoration_time > datetime.now():
# New estimated restoration time is in the future, so likely to be a real time
updated_properties.append("estimated restoration")
updated_entries.append(
"Est. Restoration: {}".format(
estimated_restoration_time.strftime(post_datetime_format)
)
2024-01-13 14:24:00 -08:00
)
if event["cause"] != existing_record.cause:
existing_record.cause = event["cause"]
updated_properties.append("cause")
updated_entries.append("Cause: {}".format(event["cause"]))
2024-01-30 07:58:11 -08:00
previous_event_class = classify_event_size(existing_record.num_people)
if event_class["size"] != previous_event_class["size"]:
2024-01-13 14:24:00 -08:00
updated_properties.append("outage size")
2024-01-30 07:58:11 -08:00
updated_entries.append("Outage Size: {}".format(event_class["size"]))
2024-01-13 14:24:00 -08:00
if status != existing_record.status:
existing_record.status = status
updated_properties.append("status")
updated_entries.append("Status: {}".format(status))
2024-01-29 19:50:37 -08:00
if existing_record.num_people != event["numPeople"]:
existing_record.num_people = event["numPeople"]
2024-01-30 07:58:11 -08:00
if existing_record.max_num_people < event["numPeople"]:
# Used to determine the maximum number of people affected by this outage, to determine if it's worth posting about
existing_record.max_num_people = event["numPeople"]
max_event_class = classify_event_size(existing_record.max_num_people)
2024-01-13 14:24:00 -08:00
if updated_properties:
updated_properties.sort()
updated_entries.sort()
if len(updated_properties) == 1:
updated_entries.insert(
0,
"The {} of this outage has been updated.\n".format(
updated_properties[0]
),
)
else:
# TODO: this currently just smashes all of the properties together with commas, it'd be nice to make it actually format it like a sentence
updated_entries.insert(
0,
"The {} of this outage have been updated.\n".format(
", ".join(updated_properties)
),
)
if max_event_class["is_postable"] and existing_record.initial_post_id:
2024-09-11 19:16:52 -07:00
try:
post_result = mastodon_client.status_post(
status="\n".join(updated_entries),
in_reply_to_id=existing_record.most_recent_post_id,
visibility="unlisted",
language="en",
)
existing_record.most_recent_post_id = post_result["id"]
except mastodon.MastodonNotFoundError:
print(
"Could not post a reply to the existing post, skip this update"
)
elif max_event_class["is_postable"]:
print(
2024-02-15 07:53:17 -08:00
"Posting an event that grew above the threshold required to post"
)
2024-02-15 07:53:17 -08:00
initial_post_result = do_initial_post(
event,
event_class,
start_time,
estimated_restoration_time,
outage_geometries,
2024-02-15 07:53:17 -08:00
)
try:
existing_record.neighborhood = initial_post_result[
"neighborhood"
]
except KeyError:
pass
try:
existing_record.city = initial_post_result["city"]
except KeyError:
pass
2024-02-15 07:53:17 -08:00
existing_record.initial_post_id = initial_post_result["post_id"]
existing_record.most_recent_post_id = initial_post_result["post_id"]
existing_record.map_media_post_id = initial_post_result[
"map_media_post_id"
]
session.commit()
2024-01-13 14:24:00 -08:00
except NoResultFound:
2024-01-29 19:42:12 -08:00
print("Existing record not found")
2024-01-30 16:08:54 -08:00
post_id = None
map_media_post_id = None
2024-10-27 10:30:54 -07:00
neighborhood = None
city = None
2024-01-30 07:58:11 -08:00
if not event_class["is_postable"]:
2024-01-30 16:00:00 -08:00
print(
"Outage is {} considered postable, will not post".format(
event_class["size"]
)
)
2024-01-29 19:42:12 -08:00
else:
2024-02-15 07:53:17 -08:00
initial_post_result = do_initial_post(
event,
event_class,
start_time,
estimated_restoration_time,
outage_geometries,
2024-01-29 19:42:12 -08:00
)
2024-02-15 07:53:17 -08:00
post_id = initial_post_result["post_id"]
map_media_post_id = initial_post_result["map_media_post_id"]
2024-01-13 14:24:00 -08:00
try:
neighborhood = initial_post_result["neighborhood"]
except KeyError:
pass
try:
city = initial_post_result["city"]
except KeyError:
pass
2024-01-13 14:24:00 -08:00
new_outage_record = SclOutage(
scl_outage_id=event["id"],
outage_user_id=event["identifier"],
2024-01-30 16:08:54 -08:00
most_recent_post_id=post_id,
initial_post_id=post_id,
map_media_post_id=map_media_post_id,
2024-01-13 14:24:00 -08:00
last_updated_time=last_updated_time,
estimated_restoration_time=estimated_restoration_time,
cause=event["cause"],
status=status,
2024-01-29 19:42:12 -08:00
start_time=start_time,
2024-01-30 07:19:13 -08:00
num_people=event["numPeople"],
2024-01-30 07:58:11 -08:00
max_num_people=event["numPeople"],
neighborhood=neighborhood,
city=city,
2024-01-13 14:24:00 -08:00
)
session.add(new_outage_record)
session.commit()
lookup_active_outages_statement = select(SclOutage).where(
2024-02-10 20:51:41 -08:00
SclOutage.no_longer_in_response_time == None # noqa: E711 - Syntax must stay this way for SQLAlchemy
2024-01-13 14:24:00 -08:00
)
for active_outage in session.scalars(lookup_active_outages_statement):
2024-01-31 21:26:41 -08:00
if (
not any(event["id"] == active_outage.scl_outage_id for event in scl_events)
and scl_events
2024-01-31 21:26:41 -08:00
):
2024-01-13 14:24:00 -08:00
# Event ID no longer exists in response
if active_outage.most_recent_post_id:
2024-01-31 21:34:58 -08:00
try:
post_result = mastodon_client.status_post(
status="This outage is no longer in the SCL feed, which usually means it's either been resolved, or split into multiple smaller outages.\n\n#SeattleCityLightOutage #SCLOutage",
2024-01-31 21:34:58 -08:00
in_reply_to_id=active_outage.most_recent_post_id,
visibility="public",
language="en",
)
active_outage.most_recent_post_id = post_result["id"]
except mastodon.MastodonNotFoundError:
print(
"The outage post couldn't be replied to, it was externally deleted."
)
2024-01-13 14:24:00 -08:00
active_outage.no_longer_in_response_time = datetime.now()
session.commit()