Compare commits

..

5 commits

Author SHA1 Message Date
6ca6a2dbd1 Post another map image if the area/geometry has been modified
All checks were successful
ci/woodpecker/push/lint Pipeline was successful
ci/woodpecker/push/vulnerability-scan Pipeline was successful
ci/woodpecker/push/docker-buildx Pipeline was successful
2025-07-09 19:09:15 -07:00
000acf19e3 Split map image generation into a new function 2025-07-09 19:02:17 -07:00
3c40e695a7 Fix Pillow attribution rectangle fill color 2025-07-09 19:01:17 -07:00
635097da91 Change POST_DATETIME_FORMAT to const 2025-07-09 18:56:51 -07:00
2bfd4880f4 Update config-sample 2025-07-09 17:50:21 -07:00
2 changed files with 127 additions and 110 deletions

View file

@ -1,8 +1,10 @@
osm: osm:
url_template: https://tile.openstreetmap.org/{z}/{x}/{y}.png url_template: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: © OpenStreetMap attribution: © OpenStreetMap
health_check_url:
nominatim: nominatim:
api_base_url: api_base_url: https://nominatim.openstreetmap.org
health_check_url: https://nominatim.openstreetmap.org/status
mastodon: mastodon:
client_id: client_id:
client_secret: client_secret:

55
scl.py
View file

@ -1,7 +1,7 @@
import io import io
import re import re
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, Tuple
import mastodon import mastodon
import requests import requests
@ -9,6 +9,7 @@ import shapely
import staticmap import staticmap
import yaml import yaml
from mastodon import Mastodon from mastodon import Mastodon
from mastodon.return_types import MediaAttachment
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from shapely import Geometry from shapely import Geometry
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
@ -18,7 +19,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from geospatial import DBGeometry, convert_outage_geometry from geospatial import DBGeometry, convert_outage_geometry
REQUESTS_HEADERS = {"User-Agent": "seattlecitylight-mastodon-bot"} REQUESTS_HEADERS = {"User-Agent": "seattlecitylight-mastodon-bot"}
post_datetime_format = "%b %e %l:%M %p" POST_DATETIME_FORMAT = "%b %e %l:%M %p"
scl_events_url = "https://utilisocial.io/datacapable/v2/p/scl/map/events" scl_events_url = "https://utilisocial.io/datacapable/v2/p/scl/map/events"
scl_events_response = requests.get(scl_events_url) scl_events_response = requests.get(scl_events_url)
@ -83,7 +84,6 @@ class AttribStaticMap(staticmap.StaticMap, object):
textSize = fnt.getbbox(self.attribution) textSize = fnt.getbbox(self.attribution)
textPosition = (image.size[0] - textSize[2], image.size[1] - textSize[3]) textPosition = (image.size[0] - textSize[2], image.size[1] - textSize[3])
offset = 2 offset = 2
options = {"fill": (255, 255, 255, 180)}
d.rectangle( d.rectangle(
[ [
(textPosition[0] - (2 * offset), textPosition[1] - (2 * offset)), (textPosition[0] - (2 * offset), textPosition[1] - (2 * offset)),
@ -92,7 +92,7 @@ class AttribStaticMap(staticmap.StaticMap, object):
textSize[3] + textPosition[1] + (2 * offset), textSize[3] + textPosition[1] + (2 * offset),
), ),
], ],
**options, fill=(255, 255, 255, 180),
) )
# draw text, full opacity # draw text, full opacity
@ -152,18 +152,11 @@ def get_hashtag_string(event) -> str:
return hashtag_string return hashtag_string
def do_initial_post( def generate_post_map_image(
event, event, event_class, outage_geometries: shapely.MultiPolygon
event_class, ) -> Tuple[MediaAttachment, str]:
start_time: datetime,
estimated_restoration_time: datetime,
outage_geometries: shapely.MultiPolygon,
) -> 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 # Fallback location from the SCL API in case one couldn't be reverse geocoded
area_text = event["city"] area_text = event["city"]
try:
map = AttribStaticMap( map = AttribStaticMap(
1024, 1024,
1024, 1024,
@ -254,24 +247,40 @@ def do_initial_post(
with io.BytesIO() as map_image_file: with io.BytesIO() as map_image_file:
map_image.save(map_image_file, format="WebP", method=6) map_image.save(map_image_file, format="WebP", method=6)
map_media_post = mastodon_client.media_post( map_media_post = mastodon_client.media_post(
map_image_file.getvalue(), map_image_file,
mime_type="image/webp", mime_type="image/webp",
description=alt_text, description=alt_text,
) )
map_media_post_id = map_media_post["id"] return (map_media_post, area_text)
def do_initial_post(
event,
event_class,
start_time: datetime,
estimated_restoration_time: datetime,
outage_geometries: shapely.MultiPolygon,
) -> dict[str, str | None]:
post_id = None
map_media_post = None
area_text = str()
try:
map_media_post, area_text = generate_post_map_image(
event, event_class, outage_geometries
)
except Exception as e: except Exception as e:
print(e) print(e)
print( print(
"Ran into an issue with generating/uploading the map. Will post without it." "Ran into an issue with generating/uploading the map. Will post without it."
) )
map_media_post = None
hashtag_string = get_hashtag_string(event) hashtag_string = get_hashtag_string(event)
est_restoration_post_text = str() est_restoration_post_text = str()
if estimated_restoration_time > datetime.now(): if estimated_restoration_time > datetime.now():
est_restoration_post_text = "\nEst. Restoration: {}\n".format( est_restoration_post_text = "\nEst. Restoration: {}\n".format(
estimated_restoration_time.strftime(post_datetime_format) estimated_restoration_time.strftime(POST_DATETIME_FORMAT)
) )
post_text = """Seattle City Light is reporting a {} outage in {}. post_text = """Seattle City Light is reporting a {} outage in {}.
@ -282,7 +291,7 @@ Cause: {}
{}""".format( {}""".format(
event_class["size"].lower(), event_class["size"].lower(),
area_text, area_text,
start_time.strftime(post_datetime_format), start_time.strftime(POST_DATETIME_FORMAT),
est_restoration_post_text, est_restoration_post_text,
event["cause"], event["cause"],
hashtag_string, hashtag_string,
@ -364,6 +373,7 @@ with Session(engine) as session:
existing_record = lookup_result.one() existing_record = lookup_result.one()
updated_properties = [] updated_properties = []
updated_entries = [] updated_entries = []
map_media_post = None
est_restoration_diff_mins = ( est_restoration_diff_mins = (
abs( abs(
@ -382,7 +392,7 @@ with Session(engine) as session:
updated_properties.append("estimated restoration") updated_properties.append("estimated restoration")
updated_entries.append( updated_entries.append(
"Est. Restoration: {}".format( "Est. Restoration: {}".format(
estimated_restoration_time.strftime(post_datetime_format) estimated_restoration_time.strftime(POST_DATETIME_FORMAT)
) )
) )
if event["cause"] != existing_record.cause: if event["cause"] != existing_record.cause:
@ -406,8 +416,12 @@ with Session(engine) as session:
max_event_class = classify_event_size(existing_record.max_num_people) max_event_class = classify_event_size(existing_record.max_num_people)
if existing_record.outage_geometries != outage_geometries: if existing_record.outage_geometries != outage_geometries:
print("Geometries modified") print("Geometries modified")
updated_properties.append("area")
existing_record.outage_geometries = outage_geometries existing_record.outage_geometries = outage_geometries
existing_record.geometries_modified = True existing_record.geometries_modified = True
map_media_post, _ = generate_post_map_image(
event, event_class, outage_geometries
)
if updated_properties: if updated_properties:
updated_properties.sort() updated_properties.sort()
@ -432,6 +446,7 @@ with Session(engine) as session:
post_result = mastodon_client.status_post( post_result = mastodon_client.status_post(
status="\n".join(updated_entries), status="\n".join(updated_entries),
in_reply_to_id=existing_record.most_recent_post_id, in_reply_to_id=existing_record.most_recent_post_id,
media_ids=map_media_post,
visibility="public", visibility="public",
language="en", language="en",
) )