feat: map of route in /info embed

This commit is contained in:
Kaaninchen
2026-08-19 22:16:56 +02:00
parent cd2d968545
commit 714841df22
11 changed files with 173 additions and 19 deletions
+1
View File
@@ -1,6 +1,7 @@
venv/ venv/
__pycache__/ __pycache__/
src/data/announcements/* src/data/announcements/*
src/data/assets/current_map.png
!src/data/announcements/.gitkeep !src/data/announcements/.gitkeep
config.json config.json
stations.json stations.json
+2 -2
View File
@@ -158,7 +158,7 @@ The bot can join the voice chat, play an audio file, and disconnect from the voi
Place the audio file of your desired station in [src/data/announcements](src/data/announcements/) with the EXACT name of the station. The bot will automatically check if an audio file with the stations name exists, and if it does, play it. Place the audio file of your desired station in [src/data/announcements](src/data/announcements/) with the EXACT name of the station. The bot will automatically check if an audio file with the stations name exists, and if it does, play it.
#### http #### http
- `"user_agent"`: The user agent of the bot for the API. If you don't know what that is, then you shouldn't have to change that. Even if you do, you still probably don't have to - `"user_agent"`: The user agent of the bot for the API. If you don't know what that is, then you shouldn't have to change that.
## Running ## Running
After you've set everything up, you're ready to start the bot! After you've set everything up, you're ready to start the bot!
@@ -186,5 +186,5 @@ Covered in [locales](#locales)
### [announcements/](src/data/announcements/) ### [announcements/](src/data/announcements/)
Covered in [voice announcements](#voice) Covered in [voice announcements](#voice)
### [announcements](src/data/announcements/) ### [assets](src/data/assets/)
Assets (such as images) which the bot uses. You shouldn't have to change anything there Assets (such as images) which the bot uses. You shouldn't have to change anything there
+1
View File
@@ -13,6 +13,7 @@ numpy==2.5.1
pandas==3.0.3 pandas==3.0.3
propcache==0.5.2 propcache==0.5.2
py-cord==2.8.0 py-cord==2.8.0
py-staticmaps==0.5.0
pyyaml==6.0.3 pyyaml==6.0.3
pycparser==3.0 pycparser==3.0
pynacl==1.6.2 pynacl==1.6.2
+15 -7
View File
@@ -249,7 +249,7 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
trip_details = { trip_details = {
"long_name": long_name, "long_name": long_name,
"short_name": display_name, "short_name": train_name,
"station": from_station, "station": from_station,
"from": train_from, "from": train_from,
"to": goes_to, "to": goes_to,
@@ -266,7 +266,9 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
train_from_importance = legs["from"]["importance"] train_from_importance = legs["from"]["importance"]
trip_details["stops"][train_from] = { trip_details["stops"][train_from] = {
"arrival": departure_dt, "arrival": departure_dt,
"importance": train_from_importance "importance": train_from_importance,
"lat": legs["from"]["lat"],
"lon": legs["from"]["lon"]
} }
departure_time_iso = start_time departure_time_iso = start_time
@@ -276,22 +278,28 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
stop_importance = stop["importance"] stop_importance = stop["importance"]
stop_details = { stop_details = {
"arrival": stop_arrival_dt, "arrival": stop_arrival_dt,
"importance": stop_importance "importance": stop_importance,
"lat": stop["lat"],
"lon": stop["lon"]
} }
trip_details["stops"][stop["name"]] = stop_details trip_details["stops"][stop["name"]] = stop_details
if stop.get("name") == from_station: if stop.get("name") == from_station:
departure_time_iso = stop["departure"] departure_time = parse_iso(stop["departure"])
trip_details["departure"] = parse_iso(departure_time_iso).strftime("%H:%M") trip_details["departure_dt"] = departure_time
trip_details["departure"] = departure_time.strftime("%H:%M")
train_to_importance = legs["to"]["importance"] train_to_importance = legs["to"]["importance"]
trip_details["stops"][goes_to] = { trip_details["stops"][goes_to] = {
"arrival": arrival_dt, "arrival": arrival_dt,
"importance": train_to_importance "importance": train_to_importance,
"lat": legs["to"]["lat"],
"lon": legs["to"]["lon"]
} }
valid = validate_connection(start_time, end_time, departure_time_iso) valid = validate_connection(start_time, end_time, departure_time_iso)
if not valid: if not valid:
return None return None
return trip_details return trip_details
+1 -1
View File
@@ -4,7 +4,7 @@ channel:
train_via: "{train_name} nach {goes_to} über {from_station}" train_via: "{train_name} nach {goes_to} über {from_station}"
embeds: embeds:
footer: footer:
notice: "Daten großzügig bereitgestellt von https://transitous.org" notice: "Daten bereitgestellt von https://transitous.org • Maps (C) CARTO (C) OpenStreetMap.org contributors"
info: info:
description: "Abfahrt von {station} um {departure}. Ankunft um {arrival}" description: "Abfahrt von {station} um {departure}. Ankunft um {arrival}"
via: "Über" via: "Über"
+1 -1
View File
@@ -4,7 +4,7 @@ channel:
train_via: "{train_name} to {goes_to} via {from_station}" train_via: "{train_name} to {goes_to} via {from_station}"
embeds: embeds:
footer: footer:
notice: "Data provided by https://transitous.org" notice: "Data provided by https://transitous.org • Maps (C) CARTO (C) OpenStreetMap.org contributors"
info: info:
description: "Departure from {station} at {departure}. Arrival by {arrival}" description: "Departure from {station} at {departure}. Arrival by {arrival}"
via: "via" via: "via"
+4
View File
@@ -92,6 +92,10 @@ OPERATORS = {
"color": 0x812B6D, "color": 0x812B6D,
"slogan": ["Pour nous tous", "Donner au train des idées d'avance"] "slogan": ["Pour nous tous", "Donner au train des idées d'avance"]
}, },
"Stadtverkehr Tübingen GmbH": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/14/Stadtwerke_T%C3%BCbingen_logo.svg/330px-Stadtwerke_T%C3%BCbingen_logo.svg.png",
"color": 0x439669
},
"Tallink Grupp AS": { "Tallink Grupp AS": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Tallink_logo.svg/330px-Tallink_logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Tallink_logo.svg/330px-Tallink_logo.svg.png",
"color": 0x225197 "color": 0x225197
+3 -1
View File
@@ -4,4 +4,6 @@ from src.dc.embeds import build_info_embed
def setup_commands(bot: discord.Bot): def setup_commands(bot: discord.Bot):
@bot.slash_command(description="Informationen über die aktuelle Fahrt") @bot.slash_command(description="Informationen über die aktuelle Fahrt")
async def info(ctx): async def info(ctx):
await ctx.respond(embed=build_info_embed()) await ctx.defer()
image = discord.File("src/data/assets/current_map.png", filename="ride.png")
await ctx.respond(file=image, embed=build_info_embed())
+1
View File
@@ -61,6 +61,7 @@ def build_info_embed() -> discord.Embed:
embed.set_author(name=agency) embed.set_author(name=agency)
embed.set_thumbnail(url=metadata["logo"]) embed.set_thumbnail(url=metadata["logo"])
embed.set_image(url="attachment://ride.png")
return embed return embed
def build_announcement_embed(msg): def build_announcement_embed(msg):
+17 -6
View File
@@ -3,7 +3,7 @@ import asyncio
import random import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.utils import logger, channel_formatting, choose_connection, get_sound_path, LOCAL_TZ, get_next_station from src.utils import logger, channel_formatting, choose_connection, get_sound_path, LOCAL_TZ, get_next_station, generate_static_map
from src.config import config from src.config import config
from src.lang.locales import lang from src.lang.locales import lang
@@ -26,18 +26,25 @@ async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = Fals
if trip is None: if trip is None:
logger(f"Failed to select route after {max_attempt} attempts", "fatal") logger(f"Failed to select route after {max_attempt} attempts", "fatal")
return False return False
generate_static_map(trip["stops"], trip["mode"], trip["agency"], trip["route_color"])
arrival = trip["arrival"] arrival = trip["arrival"]
long_name = trip["long_name"]
if len(trip["long_name"]) >= 100:
channel_name = trip["short_name"]
else:
channel_name = trip["long_name"]
mode = trip["mode"] mode = trip["mode"]
print("-----------------") print("-----------------")
logger(f"Transfer: {long_name}; Arrival: {arrival}") logger(f"Transfer: {channel_name}; Arrival: {arrival}")
logger(f"Agency: {trip["agency"]}, mode: {mode}") logger(f"Agency: {trip["agency"]}, mode: {mode}")
logger(f"Trying to change the channels name. If nothing happens, then the bot is in cooldown... (automatically resolves after up to 10min)") logger(f"Trying to change the channels name. If nothing happens, then the bot is in cooldown... (automatically resolves after up to 10min)")
formatting = channel_formatting(mode) formatting = channel_formatting(mode)
await voice_channel.edit(name=f"{formatting}{long_name}") await voice_channel.edit(name=f"{formatting}{channel_name}")
await voice_channel.set_status(None) await voice_channel.set_status(None)
start_next_stop_updates(voice_channel) start_next_stop_updates(voice_channel)
@@ -59,12 +66,16 @@ async def announcer(announcement: str, voice_channel: discord.VoiceChannel, dest
embed = build_announcement_embed(lang.embeds.announcement.end_of_connection.message()) embed = build_announcement_embed(lang.embeds.announcement.end_of_connection.message())
case "transfer": case "transfer":
embed = build_info_embed() embed = build_info_embed()
image = discord.File("src/data/assets/current_map.png", filename="ride.png")
case _: case _:
logger(f"Unknown announcement: {announcement}") logger(f"Unknown announcement: {announcement}")
embed = None embed = None
if embed: if embed:
await voice_channel.send(embed=embed) if image:
await voice_channel.send(file=image, embed=embed)
else:
await voice_channel.send(embed=embed)
async def voice_announcer(station: str, voice_channel: discord.VoiceChannel) -> bool: async def voice_announcer(station: str, voice_channel: discord.VoiceChannel) -> bool:
sound_path = get_sound_path(station=station) sound_path = get_sound_path(station=station)
@@ -145,7 +156,7 @@ async def _update_next_loop(voice_channel: discord.VoiceChannel):
wait_seconds = (next_stop["arrival"] - datetime.now(LOCAL_TZ)).total_seconds() wait_seconds = (next_stop["arrival"] - datetime.now(LOCAL_TZ)).total_seconds()
if wait_seconds > 0: if wait_seconds > 0:
await asyncio.sleep(wait_seconds) await (wait_seconds)
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
+127 -1
View File
@@ -1,8 +1,8 @@
import os import os
import importlib import importlib
import random
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import staticmaps
from src.config import config from src.config import config
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -202,3 +202,129 @@ def format_stop_list(stops: dict, next_stop: str | None) -> list[tuple[str, str]
fields.append((route_page_name, "\n".join(field_lines))) fields.append((route_page_name, "\n".join(field_lines)))
return fields return fields
def generate_static_map(stops: dict, mode: str, operator: str, route_color: str):
logger("Generating a new route map..")
operator_metadata = get_operator_metadata(operator, route_color, mode)
operator_color = operator_metadata["color"]
if operator_color == 0xFFFFFF:
stop_color = staticmaps.Color(105, 105, 105, 255)
else:
stop_color = staticmaps.parse_color(f"#{operator_color:06x}")
white = staticmaps.Color(255, 255, 255, 255)
context = staticmaps.Context()
context.set_tile_provider(
staticmaps.TileProvider(
"carto-voyager",
url_pattern=(
"https://$s.basemaps.cartocdn.com/"
"rastertiles/voyager/$z/$x/$y.png"
),
shards=["a", "b", "c", "d"],
attribution="",
)
# It is against the law (and against your morals...) to not give
# OSM and carto credits for their great work
# I only removed the attribution text because I really dislike the
# ugly white box that py-staticmaps adds. Credits are still visible in the
# embeds footer
)
stop_coords = [
staticmaps.create_latlng(
stop["lat"],
stop["lon"],
)
for stop in stops.values()
]
context.add_object(
staticmaps.Line(
stop_coords,
color=white,
width=20,
)
)
context.add_object(
staticmaps.Line(
stop_coords,
color=stop_color,
width=8,
)
)
last_index = len(stop_coords) - 1
for i, point in enumerate(stop_coords):
if i == 0 or i == last_index:
context.add_object(
staticmaps.Circle(
point,
radius_km=0.1,
fill_color=white,
color=stop_color,
width=12
)
)
if i == last_index:
context.add_object(
staticmaps.Marker(
point,
color=stop_color,
size=12
)
)
elif mode not in ["TRAM", "BUS", "SUBWAY", "SUBURBAN", "METRO"]: # wayyy too many stops...
context.add_object(
staticmaps.Circle(
center=point,
fill_color=white,
radius_km=0.1,
color=stop_color,
width=10,
)
)
lats = [c.lat().degrees for c in stop_coords]
lons = [c.lng().degrees for c in stop_coords]
lat_span = max(lats) - min(lats)
lon_span = max(lons) - min(lons)
MIN_SPAN = 0.01
if lat_span < MIN_SPAN or lon_span < MIN_SPAN:
center_lat = (max(lats) + min(lats)) / 2
center_lon = (max(lons) + min(lons)) / 2
pad = MIN_SPAN / 2
context.add_object(
staticmaps.Circle(
staticmaps.create_latlng(center_lat + pad, center_lon + pad),
radius_km=0.01,
fill_color=staticmaps.TRANSPARENT,
color=staticmaps.TRANSPARENT,
width=0,
)
)
context.add_object(
staticmaps.Circle(
staticmaps.create_latlng(center_lat - pad, center_lon - pad),
radius_km=0.01,
fill_color=staticmaps.TRANSPARENT,
color=staticmaps.TRANSPARENT,
width=0,
)
)
try:
image = context.render_cairo(400, 300)
image.write_to_png("src/data/assets/current_map.png")
except RuntimeError:
logger(f"You don't have cairo installed! Because of that I can only give you a low-res png of the route...")
image = context.render_pillow(400, 300)
image.save("src/data/assets/current_map.png")
logger("Map generated!")