From 714841df2221b44f4484e46712a6de657e97b488 Mon Sep 17 00:00:00 2001 From: Kaaninchen <124433727+kaaninchen@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:16:56 +0200 Subject: [PATCH] feat: map of route in /info embed --- .gitignore | 1 + README.md | 4 +- requirements.txt | 1 + src/api/transitous.py | 22 ++++--- src/data/locales/de.yaml | 2 +- src/data/locales/en.yaml | 2 +- src/data/operators.py | 4 ++ src/dc/commands.py | 4 +- src/dc/embeds.py | 1 + src/dc/handlers.py | 23 +++++-- src/utils.py | 128 ++++++++++++++++++++++++++++++++++++++- 11 files changed, 173 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 1a291fb..601b00f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ venv/ __pycache__/ src/data/announcements/* +src/data/assets/current_map.png !src/data/announcements/.gitkeep config.json stations.json \ No newline at end of file diff --git a/README.md b/README.md index 638b657..c8e50f9 100644 --- a/README.md +++ b/README.md @@ -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. #### 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 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/) 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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 56144ec..99f7161 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ numpy==2.5.1 pandas==3.0.3 propcache==0.5.2 py-cord==2.8.0 +py-staticmaps==0.5.0 pyyaml==6.0.3 pycparser==3.0 pynacl==1.6.2 diff --git a/src/api/transitous.py b/src/api/transitous.py index 705e016..fb840a8 100644 --- a/src/api/transitous.py +++ b/src/api/transitous.py @@ -249,7 +249,7 @@ def get_trip_details(random_connection: dict | None) -> dict | None: trip_details = { "long_name": long_name, - "short_name": display_name, + "short_name": train_name, "station": from_station, "from": train_from, "to": goes_to, @@ -266,7 +266,9 @@ def get_trip_details(random_connection: dict | None) -> dict | None: train_from_importance = legs["from"]["importance"] trip_details["stops"][train_from] = { "arrival": departure_dt, - "importance": train_from_importance + "importance": train_from_importance, + "lat": legs["from"]["lat"], + "lon": legs["from"]["lon"] } departure_time_iso = start_time @@ -276,22 +278,28 @@ def get_trip_details(random_connection: dict | None) -> dict | None: stop_importance = stop["importance"] stop_details = { "arrival": stop_arrival_dt, - "importance": stop_importance + "importance": stop_importance, + "lat": stop["lat"], + "lon": stop["lon"] } trip_details["stops"][stop["name"]] = stop_details + if stop.get("name") == from_station: - departure_time_iso = stop["departure"] - trip_details["departure"] = parse_iso(departure_time_iso).strftime("%H:%M") + departure_time = parse_iso(stop["departure"]) + trip_details["departure_dt"] = departure_time + trip_details["departure"] = departure_time.strftime("%H:%M") train_to_importance = legs["to"]["importance"] trip_details["stops"][goes_to] = { "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) if not valid: return None - + return trip_details diff --git a/src/data/locales/de.yaml b/src/data/locales/de.yaml index 4144a54..22da562 100644 --- a/src/data/locales/de.yaml +++ b/src/data/locales/de.yaml @@ -4,7 +4,7 @@ channel: train_via: "{train_name} nach {goes_to} über {from_station}" embeds: 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: description: "Abfahrt von {station} um {departure}. Ankunft um {arrival}" via: "Über" diff --git a/src/data/locales/en.yaml b/src/data/locales/en.yaml index 70fd906..8540c71 100644 --- a/src/data/locales/en.yaml +++ b/src/data/locales/en.yaml @@ -4,7 +4,7 @@ channel: train_via: "{train_name} to {goes_to} via {from_station}" embeds: footer: - notice: "Data provided by https://transitous.org" + notice: "Data provided by https://transitous.org • Maps (C) CARTO (C) OpenStreetMap.org contributors" info: description: "Departure from {station} at {departure}. Arrival by {arrival}" via: "via" diff --git a/src/data/operators.py b/src/data/operators.py index 579cddf..0a09f5d 100644 --- a/src/data/operators.py +++ b/src/data/operators.py @@ -92,6 +92,10 @@ OPERATORS = { "color": 0x812B6D, "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": { "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Tallink_logo.svg/330px-Tallink_logo.svg.png", "color": 0x225197 diff --git a/src/dc/commands.py b/src/dc/commands.py index 339e466..de15beb 100644 --- a/src/dc/commands.py +++ b/src/dc/commands.py @@ -4,4 +4,6 @@ from src.dc.embeds import build_info_embed def setup_commands(bot: discord.Bot): @bot.slash_command(description="Informationen über die aktuelle Fahrt") async def info(ctx): - await ctx.respond(embed=build_info_embed()) \ No newline at end of file + await ctx.defer() + image = discord.File("src/data/assets/current_map.png", filename="ride.png") + await ctx.respond(file=image, embed=build_info_embed()) \ No newline at end of file diff --git a/src/dc/embeds.py b/src/dc/embeds.py index c203343..096d6d9 100644 --- a/src/dc/embeds.py +++ b/src/dc/embeds.py @@ -61,6 +61,7 @@ def build_info_embed() -> discord.Embed: embed.set_author(name=agency) embed.set_thumbnail(url=metadata["logo"]) + embed.set_image(url="attachment://ride.png") return embed def build_announcement_embed(msg): diff --git a/src/dc/handlers.py b/src/dc/handlers.py index 233dae8..2ea55a4 100644 --- a/src/dc/handlers.py +++ b/src/dc/handlers.py @@ -3,7 +3,7 @@ import asyncio import random 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.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: logger(f"Failed to select route after {max_attempt} attempts", "fatal") return False + + generate_static_map(trip["stops"], trip["mode"], trip["agency"], trip["route_color"]) 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"] print("-----------------") - logger(f"Transfer: {long_name}; Arrival: {arrival}") + logger(f"Transfer: {channel_name}; Arrival: {arrival}") 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)") 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) 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()) case "transfer": embed = build_info_embed() + image = discord.File("src/data/assets/current_map.png", filename="ride.png") case _: logger(f"Unknown announcement: {announcement}") embed = None 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: 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() if wait_seconds > 0: - await asyncio.sleep(wait_seconds) + await (wait_seconds) except asyncio.CancelledError: raise diff --git a/src/utils.py b/src/utils.py index ab34b20..427952d 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,8 +1,8 @@ import os import importlib -import random from pathlib import Path from datetime import datetime, timedelta, timezone +import staticmaps from src.config import config 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))) 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!")