Compare commits

...
28 Commits
Author SHA1 Message Date
Kaaninchen f65aa3cfa0 fix grammatical error in NS slogans (how embarassing)... 2026-08-28 19:53:49 +02:00
Kaaninchen e9708d435e fix example config 2026-08-28 19:42:32 +02:00
Kaaninchen d50cf0277b readme 2026-08-28 15:51:41 +02:00
Kaaninchen dde0c443de map improvements 2026-08-27 13:10:30 +02:00
Kaaninchen 93b9712394 up max_attempt to 30 2026-08-24 07:55:06 +02:00
Kaaninchen 21a279839d update favicon 2026-08-23 17:57:01 +02:00
Kaaninchen 1c006731a4 feat: priority list 2026-08-23 17:21:22 +02:00
Kaaninchen db43ee4279 forgot to include de.yaml 2026-08-23 16:10:11 +02:00
Kaaninchen dc2f9dade0 move footer_notice from lang files to source code 2026-08-23 16:09:52 +02:00
Kaaninchen 10b6ee560e README: rename python run file to python file 2026-08-23 13:39:48 +02:00
Kaaninchen 53ded15209 fix: actually intercept voice_channel status error 2026-08-22 23:38:21 +02:00
Kaaninchen c24749383b fix: make the bot API conform
i am SO sorry transitous team I only saw the notice now
2026-08-21 23:48:45 +02:00
Kaaninchen 5609a3c425 fix: intercept voice_channel status error 2026-08-21 18:40:44 +02:00
Kaaninchen 8dfa092229 fix: no attribute strftime 2026-08-20 22:29:46 +02:00
Kaaninchen 62d5569bcc fix: return raw departure if start name == from_station (fixes min_duration) 2026-08-20 22:26:35 +02:00
Kaaninchen f716b770dc fix: config.json.example (2) 2026-08-20 21:41:51 +02:00
Kaaninchen 9e7c645724 fix: config.json.example 2026-08-20 21:41:24 +02:00
Kaaninchen 8c4e78c493 operators.py: S-Bahn Berlin GmbH 2026-08-20 21:40:09 +02:00
Kaaninchen 7e47874f78 remove circles on layovers in the embed map
they were ugly and also took forever to generate
2026-08-20 17:53:22 +02:00
Kaaninchen 1069879680 operators.py: Flixbus-eu 2026-08-20 17:49:19 +02:00
Kaaninchen a4ae134ca0 fix: end_of_connection announcement 2026-08-20 17:46:47 +02:00
Kaaninchen 054ab7fc42 fix: shorten channel name if it's too long for discords limit 2026-08-20 13:22:14 +02:00
Kaaninchen 999fe141b9 operators.py: Aktivbus Flensburg GmbH 2026-08-20 10:35:19 +02:00
Kaaninchen 595e7160df fix next_status_loop 2026-08-19 22:48:19 +02:00
Kaaninchen ead53f2f75 operators.py: Verkehrsbetrieb Potsdam GmbH 2026-08-19 22:44:33 +02:00
Kaaninchen ed7ae614cc operators.py: Erfurter Bahn 2026-08-19 22:17:54 +02:00
Kaaninchen 714841df22 feat: map of route in /info embed 2026-08-19 22:16:56 +02:00
Kaaninchen cd2d968545 operators.py: slogans 2026-08-19 11:48:29 +02:00
17 changed files with 292 additions and 128 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
+36 -7
View File
@@ -4,7 +4,7 @@ Gleiswechsel [ˈɡlaɪsvɛksəl] (german for "platform change") is a selfhostabl
![Example Channel](.github/preview_kanal.png) ![Example Channel](.github/preview_kanal.png)
## Features ## Features
- [International support](https://transitous.org/sources/) for both countries and cities - [International support](https://transitous.org/sources/) for countries as well as cities
- Support for various types of transportations, including but not limited to Subways, Busses, Funiculars, Trams and Trains - Support for various types of transportations, including but not limited to Subways, Busses, Funiculars, Trams and Trains
- Multi language support (+ option to easily add more languages) - Multi language support (+ option to easily add more languages)
- Announcements, including the option to join the voice chat and play an audio file while arriving at a specific station - Announcements, including the option to join the voice chat and play an audio file while arriving at a specific station
@@ -82,7 +82,7 @@ but you can safely ignore this warning if you're fine with it.
If not, I've created a tool which would help you to get the exact station name. You can use the tool by running If not, I've created a tool which would help you to get the exact station name. You can use the tool by running
```sh ```sh
$ python run main.py stations $ python main.py stations
``` ```
Example output: Example output:
@@ -121,12 +121,35 @@ For every station inside of the `stations.json` you'll find an ID. You can add t
This is especially useful if you want to add a station whose name isn't unique and also used by other stations. The bot would falsely use the first station with the same name and consider it an exact match, even if you wanted a different one. This won't happen with the ID, as every ID is uniquely assigned to only one station. This is especially useful if you want to add a station whose name isn't unique and also used by other stations. The bot would falsely use the first station with the same name and consider it an exact match, even if you wanted a different one. This won't happen with the ID, as every ID is uniquely assigned to only one station.
##### priority
You can define priorities of transport modes while selecting a connection. Leave empty to disable
The list should be descending, with "1" as the most important transport mode. For example, let's say, the bot should always choose Trains and other longer distance public transport modes. If it can't find any, then it should resort to Metros/Subways/Suburbans. If these are also missing at that station, then it should just use Busses/Trams:
```json
"priority": {
"HIGHSPEED_RAIL": 1,
"LONG_DISTANCE": 1,
"NIGHT_RAIL": 1,
"REGIONAL_RAIL": 1,
"COACH": 1,
"FERRY": 1,
"METRO": 2,
"SUBURBAN": 2,
"SUBWAY": 2,
"BUS": 3,
"TRAM": 3
}
```
A transport mode that isn't in the priority list would immediately get assigned the value of "99", making it near impossible to get that connection. Except when there are no other connections available, of course.
You can find the MODE names either in the console log or the stations.json from [helper tool](#stations)
##### blacklist ##### blacklist
You can blacklist specific types of transport, the bot would then skip them while selecting a connection. You can get the type either in your console (mode) You can blacklist specific types of transport, the bot would then skip them while selecting a connection. You can get the type in your console (mode)
```sh ```sh
22:44:47: INFO: Agency: GVB, mode: TRAM 22:44:47: INFO: Agency: GVB, mode: TRAM
``` ```
or in the `/info` embed.
I highly recommend keeping `"OTHER"` blacklisted, if the API doesn't know what that is then we probably shouldn't use it. Also, it would probably a good idea to keep `"RIDE_SHARING"` blacklisted as they have weird timetables I highly recommend keeping `"OTHER"` blacklisted, if the API doesn't know what that is then we probably shouldn't use it. Also, it would probably a good idea to keep `"RIDE_SHARING"` blacklisted as they have weird timetables
@@ -156,15 +179,21 @@ To reduce spam, the bot will only send announcements if someone is in the voice
The bot can join the voice chat, play an audio file, and disconnect from the voice chat, at various points of your trip. You have to have [FFmpeg](https://www.ffmpeg.org/) installed for this to work. The bot can join the voice chat, play an audio file, and disconnect from the voice chat, at various points of your trip. You have to have [FFmpeg](https://www.ffmpeg.org/) installed for this to work.
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.
You don't have to restart the bot after placing new audio files in the folder.
#### `map:`
The bot can generate a map of your route in the `/info` embed. Set `map` to `true` to enable.
#### 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. Modify the contact URL (in my case, the github repo) if you change a lot of the code, so that transitous can contact you in case of any issues
## 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!
```sh ```sh
$ python run main.py $ python main.py
``` ```
## src/data ## src/data
@@ -186,5 +215,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
+8 -6
View File
@@ -1,8 +1,8 @@
{ {
"discord": { "discord": {
"token": "", "token": "",
"server": , "server": "",
"vc": , "vc": "",
"lang": "de", "lang": "de",
"formatting": "┇", "formatting": "┇",
"emojis": true "emojis": true
@@ -13,7 +13,8 @@
"Amsterdam", "Amsterdam",
"Helsinki" "Helsinki"
], ],
"IDs: [] "IDs": [],
"priority": {},
"blacklist": [ "blacklist": [
"OTHER", "OTHER",
"RIDE_SHARING" "RIDE_SHARING"
@@ -25,9 +26,10 @@
}, },
"announcements": { "announcements": {
"text_announcements": true, "text_announcements": true,
"voice_announcements": false "voice_announcements": false,
"map": true
}, },
"http": { "http": {
"user_agent": "Gleiswechsel-Discord-Bot" "user_agent": "Gleiswechsel-Discord-Bot/1.0 (https://github.com/kaaninchen/Gleiswechsel)"
} }
} }
+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[cairo]==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
+60 -59
View File
@@ -11,7 +11,6 @@ from src.lang.locales import lang
station_names = config.connections.stations station_names = config.connections.stations
station_IDs = config.connections.IDs station_IDs = config.connections.IDs
blacklist = config.connections.blacklist blacklist = config.connections.blacklist
long_name_lang = lang.channel.long_name
user_agent = config.http.user_agent user_agent = config.http.user_agent
headers = { headers = {
@@ -143,10 +142,12 @@ def get_random_stop_id() -> str | None:
return None return None
stop_ids_list = list(stop_ids.keys()) stop_ids_list = list(stop_ids.keys())
chosen_stop_id = random.choice(stop_ids_list)
if len(stop_ids_list) > 1: if len(stop_ids_list) > 1:
logger(f"No station associated as '{assigned_station}', choosing random from similar named stations") logger(f"No station associated as '{assigned_station}', choosing random from similar named stations")
logger(f"Run `python run main.py stations` to get exact station names") logger(f"Run `python run main.py stations` to get exact station names")
chosen_stop_id, chosen_station = random.choice(list(stop_ids.items())) logger(f"Selected station: {stop_ids[chosen_stop_id]}")
return chosen_stop_id return chosen_stop_id
def get_random_connection(stop_id: str) -> str | None: def get_random_connection(stop_id: str) -> str | None:
@@ -154,60 +155,46 @@ def get_random_connection(stop_id: str) -> str | None:
return None return None
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
cursor = None params = {"stopId": stop_id, "n": 50, "time": now}
max_pages = 5
min_results = 5
trip_ids = []
all_stop_times = []
for _ in range(max_pages): try:
params = { response = requests.get(f"{endpoint}/api/v5/stoptimes", params=params, headers=headers)
"stopId": stop_id, response.raise_for_status()
"n": 20, data = response.json()
"time": now, except requests.RequestException as e:
} logger(e, "Error")
if cursor: return None
params["pageCursor"] = cursor
try: candidates = []
response = requests.get(f"{endpoint}/api/v5/stoptimes", params=params, headers=headers) for entry in data.get("stopTimes", []):
response.raise_for_status() if entry["mode"] in blacklist:
data = response.json() continue
except requests.RequestException as e: candidates.append(entry)
logger(e, "Error")
break
stop_times = data.get("stopTimes", []) if not candidates:
all_stop_times.extend(stop_times) logger("Couldn't find any connections", "error")
return None
if config.connections.priority:
best_priority = None
for entry in candidates:
priority = config.connections.priority.get(entry["mode"], 99)
if best_priority is None or priority < best_priority:
best_priority = priority
for entry in stop_times: best_candidates = []
trip_id = entry["tripId"] for entry in candidates:
if entry["mode"] in blacklist: priority = config.connections.priority.get(entry["mode"], 99)
continue if priority == best_priority:
else: best_candidates.append(entry)
trip_ids.append(entry["tripId"])
if len(trip_ids) >= min_results: chosen = random.choice(best_candidates)
break else:
chosen = random.choice(candidates)
cursor = data.get("nextPageCursor")
if not cursor:
break
if not trip_ids:
logger("Couldn't find any connections", "error")
return None
trip_id = random.choice(trip_ids)
from_station = None
for entry in all_stop_times:
if entry.get("tripId") == trip_id:
from_station = entry.get("place", {}).get("name")
break
return { return {
"trip_id": trip_id, "trip_id": chosen["tripId"],
"from_station": from_station "from_station": chosen.get("place", {}).get("name"),
} }
def get_trip_details(random_connection: dict | None) -> dict | None: def get_trip_details(random_connection: dict | None) -> dict | None:
@@ -238,18 +225,25 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
logger(f"Bot doesn't know what to do with round trips, I'll have to implement this some day", "error") logger(f"Bot doesn't know what to do with round trips, I'll have to implement this some day", "error")
return None # TODO actually handel this return None # TODO actually handel this
all_stop_names = [train_from] + [stop["name"] for stop in legs["intermediateStops"]] + [goes_to]
if len(all_stop_names) != len(set(all_stop_names)):
logger("Route reuses station names! Skipping, to be safe...", "error")
return None # TODO this too
arrival_dt = parse_iso(end_time) arrival_dt = parse_iso(end_time)
departure_dt = parse_iso(start_time) departure_dt = parse_iso(start_time)
train_name = get_train_name(display_name, mode) # used by lang train_name = get_train_name(display_name, mode) # used by lang
if train_from == from_station: if len(lang.channel.long_name.train_from()) >= 100:
long_name = long_name_lang.train_from() channel_name = lang.channel.short_name
else: else:
long_name = long_name_lang.train_via() if train_from == from_station:
channel_name = lang.channel.long_name.train_from()
else:
channel_name = lang.channel.long_name.train_via()
trip_details = { trip_details = {
"long_name": long_name, "channel_name": channel_name,
"short_name": display_name,
"station": from_station, "station": from_station,
"from": train_from, "from": train_from,
"to": goes_to, "to": goes_to,
@@ -266,7 +260,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,9 +272,12 @@ 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_iso = stop["departure"]
trip_details["departure"] = parse_iso(departure_time_iso).strftime("%H:%M") trip_details["departure"] = parse_iso(departure_time_iso).strftime("%H:%M")
@@ -286,12 +285,14 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
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
+3 -1
View File
@@ -1,5 +1,5 @@
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Optional from typing import Optional
@dataclass @dataclass
@@ -15,6 +15,7 @@ class DiscordConfig:
class ConnectionsConfig: class ConnectionsConfig:
stations: list[str] stations: list[str]
IDs: list[str] IDs: list[str]
priority: dict
blacklist: list[str] blacklist: list[str]
min_duration: int min_duration: int
timezone: str timezone: str
@@ -25,6 +26,7 @@ class ConnectionsConfig:
class AnnouncementConfig: class AnnouncementConfig:
text_announcements: bool text_announcements: bool
voice_announcements: bool voice_announcements: bool
map: bool
@dataclass @dataclass
class HttpConfig: class HttpConfig:
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,2 +0,0 @@
SPDX-FileCopyrightText: 2024 Mathis Brüchert <[email protected]>
SPDX-License-Identifier: CC-BY-SA-4.0
+1 -2
View File
@@ -2,9 +2,8 @@ channel:
long_name: long_name:
train_from: "{train_name} nach {goes_to} von {train_from}" train_from: "{train_name} nach {goes_to} von {train_from}"
train_via: "{train_name} nach {goes_to} über {from_station}" train_via: "{train_name} nach {goes_to} über {from_station}"
short_name: "{train_name} nach {goes_to}"
embeds: embeds:
footer:
notice: "Daten großzügig bereitgestellt von https://transitous.org"
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 -2
View File
@@ -2,9 +2,8 @@ channel:
long_name: long_name:
train_from: "{train_name} to {goes_to} from {train_from}" train_from: "{train_name} to {goes_to} from {train_from}"
train_via: "{train_name} to {goes_to} via {from_station}" train_via: "{train_name} to {goes_to} via {from_station}"
short_name: "{train_name} to {goes_to}"
embeds: embeds:
footer:
notice: "Data provided by https://transitous.org"
info: info:
description: "Departure from {station} at {departure}. Arrival by {arrival}" description: "Departure from {station} at {departure}. Arrival by {arrival}"
via: "via" via: "via"
+73 -25
View File
@@ -1,96 +1,139 @@
OPERATORS = { OPERATORS = {
"Abellio Rail Mitteldeutschland GmbH": { "Abellio Rail Mitteldeutschland GmbH": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Abellio_logo.svg/1920px-Abellio_logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Abellio_logo.svg/1920px-Abellio_logo.svg.png",
"color": 0xD7002E "color": 0xD7002E,
"slogan": ["Freie Bahn für deine Ziele!"]
}, },
"Agilis": { "Agilis": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Agilis_Logo.svg/960px-Agilis_Logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Agilis_Logo.svg/960px-Agilis_Logo.svg.png",
"color": 0xCAE15B "color": 0xCAE15B,
"slogan": ["Reinsetzen, zurücklehnen", "Fahren Sie mit uns. Nehmen Sie Platz."]
},
"Aktiv Bus Flensburg GmbH": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/26/Aktiv_Bus_Flensburg_GmbH_logo.svg/960px-Aktiv_Bus_Flensburg_GmbH_logo.svg.png?utm_source=de.wikipedia.org&utm_campaign=index&utm_content=thumbnail",
"color": 0xFFC331
}, },
"Arverio Bayern": { "Arverio Bayern": {
"logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528006572805062776/Arverio_Avi_Bayern_blau_RGB.png?ex=6a5cba83&is=6a5b6903&hm=5781ae6c372c92ab4f52409c6fc91e5014ac34ccf24db665ade052e8135bdde0&animated=true", "logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528006572805062776/Arverio_Avi_Bayern_blau_RGB.png?ex=6a5cba83&is=6a5b6903&hm=5781ae6c372c92ab4f52409c6fc91e5014ac34ccf24db665ade052e8135bdde0&animated=true",
"color": 0x0083BE "color": 0x0083BE,
"slogan": ["Dein Weg ist unser Ziel."]
}, },
"Berliner Verkehrsbetriebe": { "Berliner Verkehrsbetriebe": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/BVG_Logo_07.2021.svg/960px-BVG_Logo_07.2021.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/BVG_Logo_07.2021.svg/960px-BVG_Logo_07.2021.svg.png",
"color": 0xEFD13C "color": 0xEFD13C,
"slogan": ["Weil wir dich lieben"]
}, },
"DSB": { "DSB": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/DSB_company_logo.svg/960px-DSB_company_logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/DSB_company_logo.svg/960px-DSB_company_logo.svg.png",
"color": 0xB22B32 "color": 0xB22B32,
"slogan": ["Tak for at vise hensyn"]
}, },
"enno": { "enno": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Enno_logo.svg/960px-Enno_logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Enno_logo.svg/960px-Enno_logo.svg.png",
"color": 0x88216F "color": 0x88216F,
"slogan": ["enno fährt bunt"]
}, },
"Erfurter Bahn GmbH": { "Erfurter Bahn": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/Erfurter_Bahn_logo.svg/960px-Erfurter_Bahn_logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/Erfurter_Bahn_logo.svg/960px-Erfurter_Bahn_logo.svg.png",
"color": 0x009133 "color": 0x009133,
"slogan": ["Kommt gut an."]
}, },
"eurobahn": { "eurobahn": {
"logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528405390260179146/Eurpnajm.png?ex=6a5e2df1&is=6a5cdc71&hm=d73fc3458f59f8942a5a0f51311308b12567b35b55b64aab0726bf6be92b3b0e&animated=true", "logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528405390260179146/Eurpnajm.png?ex=6a5e2df1&is=6a5cdc71&hm=d73fc3458f59f8942a5a0f51311308b12567b35b55b64aab0726bf6be92b3b0e&animated=true",
"color": 0x005a9b "color": 0x005a9b,
"slogan": ["Echte Menschen. Echt schöne Ziele."]
}, },
"European Sleeper": { "European Sleeper": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/European_Sleeper_Logo.svg/960px-European_Sleeper_Logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/European_Sleeper_Logo.svg/960px-European_Sleeper_Logo.svg.png",
"color": 0xEB4A27 "color": 0xEB4A27,
"slogan": ["The Good Night Train", "Travel while you sleep"]
}, },
"Flixbus": { "Flixbus": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Flixbus_201x_logo.svg/1280px-Flixbus_201x_logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Flixbus_201x_logo.svg/1280px-Flixbus_201x_logo.svg.png",
"color": 0x8CD541 "color": 0x8CD541,
"slogan": ["Einfach Busfahren.", "Fahr grün!", "FLIX it", "Mehr Leben im echten Leben"]
}, },
"GoVolta": { "GoVolta": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/GoVolta_logo.svg/1280px-GoVolta_logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/GoVolta_logo.svg/1280px-GoVolta_logo.svg.png",
"color": 0x0B70F6 "color": 0x0B70F6,
"slogan": ["Internationale Züge, endlich erschwinglich!", "International trains, finally affordable!"]
}, },
"GVB": { "GVB": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/BSicon_LOGO_GVB.svg/960px-BSicon_LOGO_GVB.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/BSicon_LOGO_GVB.svg/960px-BSicon_LOGO_GVB.svg.png",
"color": 0x2B62AF "color": 0x2B62AF,
},
"Jenaer Nahverkehr GmbH": {
"logo": "https://www.stadtwerke-jena.de/dam/jcr:74dc9d81-0447-4864-973b-7e395f1d6948/JNV_Bildmarke_322px.png",
"color": 0x0075BD
}, },
"Nordbahn Eisenbahngesellschaft": { "Nordbahn Eisenbahngesellschaft": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/b/b5/Logo_Nordbahn_NAH.SH_Blau_positiv_final.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/b/b5/Logo_Nordbahn_NAH.SH_Blau_positiv_final.png",
"color": 0x1A2848 "color": 0x1A2848,
"slogan": ["nordbahn kommt von Norden", "Bringt den Norden näher", "Wir transportieren das Biest"]
}, },
"NS": { "NS": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Logo_NS.svg/960px-Logo_NS.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Logo_NS.svg/960px-Logo_NS.svg.png",
"color": 0x00337F, "color": 0x00337F,
"slogan": ["Goed op weg", "Welkom in de trein van morgen", "Veilig, Vlug, Voordelig", "we haben een serious probleem", "Neuken in de keuken"] "slogan": ["Goed op weg", "Welkom in de trein van morgen", "Veilig, Vlug, Voordelig", "we hebben een serieus probleem", "Neuken in de keuken"]
}, },
"Ostdeutsche Eisenbahn GmbH": { "Ostdeutsche Eisenbahn GmbH": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/ODEG-Logo_Neu.svg/960px-ODEG-Logo_Neu.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/ODEG-Logo_Neu.svg/960px-ODEG-Logo_Neu.svg.png",
"color": 0x00745C "color": 0x00745C,
"slogan": ["Rücksicht kommt gut", "Benimm dich, als wäre Mutti dabei.", "Sicher mit der Sachsenschleuder"]
}, },
"Regionalverkehre Start Deutschland GmbH (Start Mitteldeutschland)": { "Regionalverkehre Start Deutschland GmbH (Start Mitteldeutschland)": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/Logo_der_Regionalverkehre_Start_Deutschland.svg/960px-Logo_der_Regionalverkehre_Start_Deutschland.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/Logo_der_Regionalverkehre_Start_Deutschland.svg/960px-Logo_der_Regionalverkehre_Start_Deutschland.svg.png",
"color": 0x61A731 "color": 0x61A731,
},
"S-Bahn": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/S-Bahn-Logo.svg/500px-S-Bahn-Logo.svg.png",
"color": 0x3E8B55
}, },
"S-Bahn Hannover (Transdev)": { "S-Bahn Hannover (Transdev)": {
"logo": "https://cdn.discordapp.com/attachments/1526274436523888732/1528470315234234520/sbahn.png?ex=6a5e6a68&is=6a5d18e8&hm=c9f630c6f0c646eafeccef9b4d591ce8fcf8856b2b45f14e72fbf25e32edb017&animated=true", "logo": "https://cdn.discordapp.com/attachments/1526274436523888732/1528470315234234520/sbahn.png?ex=6a5e6a68&is=6a5d18e8&hm=c9f630c6f0c646eafeccef9b4d591ce8fcf8856b2b45f14e72fbf25e32edb017&animated=true",
"color": 0x1A4389 "color": 0x1A4389,
"slogan": ["Wir geben alles", "Wir bringen dich", "Wir bringen Respekt"]
}, },
"SBB": { "SBB": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/SBB_logo_simplified.svg/960px-SBB_logo_simplified.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/SBB_logo_simplified.svg/960px-SBB_logo_simplified.svg.png",
"color": 0xEB0000 "color": 0xEB0000,
"slogan": ["Der Kluge reist im Zuge", "SBB schafft Verbindungen", "Unterwegs zuhause"]
}, },
"SNCF": { "SNCF": {
"logo": "https://upload.wikimedia.org/wikipedia/en/thumb/f/f4/Sncf-logo.svg/960px-Sncf-logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/en/thumb/f/f4/Sncf-logo.svg/960px-Sncf-logo.svg.png",
"color": 0x812B6D "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
},
"Südwestdeutsche Verkehrs-AG": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/SWEG_Logo.svg/330px-SWEG_Logo.svg.png?utm_source=de.wikipedia.org&utm_campaign=index&utm_content=thumbnail",
"color": 0x1A509A
}, },
"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
}, },
"Verkehrsbetrieb Potsdam GmbH": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Verkehrsbetrieb_Potsdam_logo.svg/330px-Verkehrsbetrieb_Potsdam_logo.svg.png",
"color": 0x000000
},
"Vr": { "Vr": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/Vr_Logo.png/330px-Vr_Logo.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/Vr_Logo.png/330px-Vr_Logo.png",
"color": 0x00B451 "color": 0x00B451,
"slogan": ["Aikaa yhteisellä matkalla"]
}, },
"Westbahn Management GmbH": { "Westbahn Management GmbH": {
"logo": "https://corporate.westbahn.at/uploads/Logos/westbahn2025-logo-signet-small.png", "logo": "https://corporate.westbahn.at/uploads/Logos/westbahn2025-logo-signet-small.png",
"color": 0x1D4A83 "color": 0x1D4A83,
"slogan": ["Immer ein guter Zug", "Da steig ich ein", "Jetzt aber Westbahn"]
}, },
"WestfalenBahn": { "WestfalenBahn": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Westfalenbahn_logo.svg/330px-Westfalenbahn_logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Westfalenbahn_logo.svg/330px-Westfalenbahn_logo.svg.png",
"color": 0x48A1DD "color": 0x48A1DD,
"slogan": ["Weil's meine Region ist", "Hier bin ich daheim!"]
}, },
"db_allgemein": { "db_allgemein": {
"logo": "https://marketingportal.extranet.deutschebahn.com/resource/blob/13602522/c53f806b9df966e144010b276af72dd2/Bild_09-data.png", "logo": "https://marketingportal.extranet.deutschebahn.com/resource/blob/13602522/c53f806b9df966e144010b276af72dd2/Bild_09-data.png",
@@ -104,7 +147,8 @@ OPERATORS = {
}, },
"db_bayern": { "db_bayern": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Bahnland_Bayern_Logo_2021.svg/500px-Bahnland_Bayern_Logo_2021.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Bahnland_Bayern_Logo_2021.svg/500px-Bahnland_Bayern_Logo_2021.svg.png",
"color": 0x0095DB "color": 0x0095DB,
"slogan": ["Zeit für dich", "Bringt dich vorwärts", "Wir bewegen das Bahnland Bayern", "Ey, i muas da ehrlich sogen i wünimma mit dir zaum sein, weil i a eif agrod gmerkt hob das i nix ernstes grod will und es liegt wirkli ned an dir oba es passt halt ned sound du müsstest holt a nü des abhoin wos du bei mir vergessen host"]
}, },
"fallback": { "fallback": {
"logo": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQCVeC5E0mNNBKyQftQaFMzxIVkbDvEnSzWv07h_c8PdA&s=10", "logo": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQCVeC5E0mNNBKyQftQaFMzxIVkbDvEnSzWv07h_c8PdA&s=10",
@@ -121,6 +165,7 @@ OPERATOR_ALIASES = {
"Bayerische Regiobahn": OPERATORS["db_bayern"], "Bayerische Regiobahn": OPERATORS["db_bayern"],
"DB Regio AG Bayern": OPERATORS["db_bayern"], "DB Regio AG Bayern": OPERATORS["db_bayern"],
"DB Fernverkehr AG": OPERATORS["db_allgemein"], "DB Fernverkehr AG": OPERATORS["db_allgemein"],
"DB Regio AG": OPERATORS["db_allgemein"],
"DB Regio AG NRW": OPERATORS["db_allgemein"], "DB Regio AG NRW": OPERATORS["db_allgemein"],
"DB Regio AG Südost": OPERATORS["db_allgemein"], "DB Regio AG Südost": OPERATORS["db_allgemein"],
"DB Regio AG Nordost": OPERATORS["db_allgemein"], "DB Regio AG Nordost": OPERATORS["db_allgemein"],
@@ -131,7 +176,10 @@ OPERATOR_ALIASES = {
"Schweizerische Bundesbahnen SBB": OPERATORS["SBB"], "Schweizerische Bundesbahnen SBB": OPERATORS["SBB"],
"Dänische Staatsbahnen": OPERATORS["DSB"], "Dänische Staatsbahnen": OPERATORS["DSB"],
"VR": OPERATORS["Vr"], "VR": OPERATORS["Vr"],
"FlixBus-gb": OPERATORS["Flixbus"] "FlixBus-gb": OPERATORS["Flixbus"],
"FlixBus-eu": OPERATORS["Flixbus"],
"S-Bahn Berlin GmbH": OPERATORS["S-Bahn"],
"DB Regio AG S-Bahn München": OPERATORS["S-Bahn"]
} }
+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())
+9 -6
View File
@@ -4,12 +4,13 @@ import random
from src.utils import get_operator_metadata, get_next_station, format_via_list, format_stop_list from src.utils import get_operator_metadata, get_next_station, format_via_list, format_stop_list
from src.dc.helpers import format_timestamp_to_dc from src.dc.helpers import format_timestamp_to_dc
from src.lang.locales import lang from src.lang.locales import lang
from src.config import config
lang_embed = lang.embeds lang_embed = lang.embeds
def build_embed_footer(mode: str, slogans): def build_embed_footer(slogans):
footer_notice = f"{lang.embeds.footer.notice()} • mode: {mode}" footer_notice = f"https://transitous.org/sources/"
icon = "https://raw.githubusercontent.com/kaaninchen/Gleiswechsel/refs/heads/main/src/data/assets/transitous-logo.png" icon = "https://api.transitous.org/favicon.png"
if slogans is not None: if slogans is not None:
footer_text = f"{random.choice(slogans)}{footer_notice}" footer_text = f"{random.choice(slogans)}{footer_notice}"
@@ -32,7 +33,7 @@ def build_info_embed() -> discord.Embed:
arrival = format_timestamp_to_dc(trip["arrival"]) arrival = format_timestamp_to_dc(trip["arrival"])
embed = discord.Embed( embed = discord.Embed(
title = trip["long_name"], title = trip["channel_name"],
description=lang_embed.info.description(), description=lang_embed.info.description(),
color = metadata["color"] color = metadata["color"]
) )
@@ -55,12 +56,14 @@ def build_info_embed() -> discord.Embed:
for field_name, field_value in route_fields: for field_name, field_value in route_fields:
embed.add_field(name=field_name, value=field_value, inline=False) embed.add_field(name=field_name, value=field_value, inline=False)
footer = build_embed_footer(trip["mode"], metadata["slogans"]) footer = build_embed_footer(metadata["slogans"])
embed.set_footer(text=footer["text"], icon_url=footer["icon"]) embed.set_footer(text=footer["text"], icon_url=footer["icon"])
embed.set_author(name=agency) embed.set_author(name=agency)
embed.set_thumbnail(url=metadata["logo"]) embed.set_thumbnail(url=metadata["logo"])
if config.announcements.map:
embed.set_image(url="attachment://ride.png")
return embed return embed
def build_announcement_embed(msg): def build_announcement_embed(msg):
@@ -77,7 +80,7 @@ def build_announcement_embed(msg):
embed.set_author(name=agency) embed.set_author(name=agency)
embed.set_thumbnail(url=metadata["logo"]) embed.set_thumbnail(url=metadata["logo"])
footer = build_embed_footer(trip["mode"], metadata.get("slogan")) footer = build_embed_footer(metadata.get("slogan"))
embed.set_footer(text=footer["text"], icon_url=footer["icon"]) embed.set_footer(text=footer["text"], icon_url=footer["icon"])
return embed return embed
+21 -7
View File
@@ -1,9 +1,10 @@
import discord import discord
import asyncio import asyncio
import aiohttp
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
@@ -17,7 +18,7 @@ async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = Fals
_scheduled_task.cancel() _scheduled_task.cancel()
attempt = 0 attempt = 0
max_attempt = 15 max_attempt = 30
trip = choose_connection() trip = choose_connection()
while trip is None and attempt < max_attempt: while trip is None and attempt < max_attempt:
attempt += 1 attempt += 1
@@ -26,18 +27,22 @@ 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
if config.announcements.map:
generate_static_map(trip["stops"], trip["mode"], trip["agency"], trip["route_color"])
arrival = trip["arrival"] arrival = trip["arrival"]
long_name = trip["long_name"] channel_name = trip["channel_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)
@@ -53,18 +58,23 @@ async def announcer(announcement: str, voice_channel: discord.VoiceChannel, dest
announcements_enabled = config.announcements.text_announcements announcements_enabled = config.announcements.text_announcements
if announcements_enabled: if announcements_enabled:
image = None
if len(voice_channel.members) > 0: if len(voice_channel.members) > 0:
match announcement: match announcement:
case "end_of_connection": case "end_of_connection":
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 and config.announcements.map:
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)
@@ -137,7 +147,11 @@ async def _update_next_loop(voice_channel: discord.VoiceChannel):
next_stop_str = next_stop["name"] next_stop_str = next_stop["name"]
status_text = f"{lang.embeds.info.next_stop()}: {next_stop_str}" status_text = f"{lang.embeds.info.next_stop()}: {next_stop_str}"
await voice_channel.set_status(status_text, reason="Next stop status")
try:
await voice_channel.set_status(status_text, reason="Next stop status")
except aiohttp.ClientConnectorDNSError as e:
logger(F"Failed to set the channel status (network error?): {e}", "error")
if config.announcements.voice_announcements: if config.announcements.voice_announcements:
if len(voice_channel.members) > 0: if len(voice_channel.members) > 0:
+2 -7
View File
@@ -1,6 +1,6 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: en.yaml # filename: en.yaml
# timestamp: 2026-08-18T20:08:48+00:00 # timestamp: 2026-08-23T14:08:25+00:00
from __future__ import annotations from __future__ import annotations
@@ -16,11 +16,7 @@ class LongName:
@dataclass @dataclass
class Channel: class Channel:
long_name: LongName long_name: LongName
short_name: str
@dataclass
class Footer:
notice: str
@dataclass @dataclass
@@ -44,7 +40,6 @@ class Announcement:
@dataclass @dataclass
class Embeds: class Embeds:
footer: Footer
info: Info info: Info
announcement: Announcement announcement: Announcement
+73 -3
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
@@ -30,16 +30,17 @@ def choose_connection() -> dict | None:
def validate_connection(start_time: str, end_time: str, departure_time_iso: str) -> bool: def validate_connection(start_time: str, end_time: str, departure_time_iso: str) -> bool:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00")) start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
start_local = start_dt.astimezone(LOCAL_TZ).strftime('%H:%M')
end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00")) end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
if end_dt < now: if end_dt < now:
logger(f"Connection is from the past: {start_dt}", "error") logger(f"Connection is from the past: {start_local}", "error")
return False return False
max_wait_time = config.connections.max_wait_time max_wait_time = config.connections.max_wait_time
if max_wait_time: if max_wait_time:
if start_dt > now + timedelta(hours=max_wait_time): if start_dt > now + timedelta(hours=max_wait_time):
logger(f"Connection is way too far in the future: {start_dt} (max_wait_time: {max_wait_time}h), retrying...", "error") logger(f"Connection is way too far in the future: {start_local} (max_wait_time: {max_wait_time}h), retrying...", "error")
return False return False
departure_time_iso_dt = datetime.fromisoformat(departure_time_iso.replace("Z", "+00:00")) departure_time_iso_dt = datetime.fromisoformat(departure_time_iso.replace("Z", "+00:00"))
@@ -202,3 +203,72 @@ 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.tile_provider_OSM)
stop_coords = [
staticmaps.create_latlng(
stop["lat"],
stop["lon"],
)
for stop in stops.values()
]
context.add_object(
staticmaps.Line(
stop_coords,
color=white,
width=15,
)
)
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
)
)
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!")