Compare commits

..
2 Commits
Author SHA1 Message Date
Kaaninchen 199cedb8b1 commit so that the new branch uploads 2026-08-18 14:33:22 +02:00
Kaaninchen 172d6d5cf1 add README notice about maintenance status 2026-08-18 14:31:47 +02:00
29 changed files with 707 additions and 1272 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 110 KiB

-1
View File
@@ -3,4 +3,3 @@ __pycache__/
src/data/announcements/* src/data/announcements/*
!src/data/announcements/.gitkeep !src/data/announcements/.gitkeep
config.json config.json
stations.json
+127 -138
View File
@@ -1,190 +1,179 @@
# Gleiswechsel # Gleiswechsel
Gleiswechsel [ˈɡlaɪsvɛksəl] (german for "platform change") is a selfhostable discord bot which regularly changes the name of a voice chat to a real public transport connection. The channel will keep the name until the connection arrived at its last stop, after that it will automatically select a new one.
![Example Channel](.github/preview_kanal.png) ## THIS BRANCH IS NO LONGER MAINTAINED
## This is the code for the legacy Gleiswechsel bot, based on the much simpler dbf API.
## Features Gleiswechel ist ein Discord Bot, welcher einen Sprachkanal zu einer real-existierenden, aktuell befahrenden Zugverbindung umbenennt. Diesen Namen behält der Kanal so lange, wie die Verbindung in echt dauert.
- [International support](https://transitous.org/sources/) for both countries and cities
- 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)
- Announcements, including the option to join the voice chat and play an audio file while arriving at a specific station
- Highly customizable
- pretty `/info` embed with some details about your current ride
![Example info](.github/preview_info.png) ![Beispiel Kanal](.github/preview_kanal.png)
Der Bot stellt außerdem den `/info` Befehl dar, welcher einem weitere Informationen zur Verbindung zurückgibt
![Beispiel info](.github/preview_info.png)
## Setup ## Setup
Follow [this guide](https://guide.pycord.dev/getting-started/creating-your-first-bot) to create your discord bot up until you have a token.
Make sure that you have [Python](https://www.python.org/) installed!
Download the repo (either with git or by clicking the big "Code" button at the start on this page, downloading the zip and extracting it) and run:
```sh ```sh
# Create a virtual environment so that the bot won't cluster your system with dependencies git clone https://github.com/kaaninchen/Gleiswechsel.git
# Mit Python
python -m venv venv python -m venv venv
# Activate your venv, you should have gotten instructions after running the first command, e.g.:
source venv/bin/activate source venv/bin/activate
# Install the required dependencies
pip install -r requirements.txt pip install -r requirements.txt
# Mit uv
uv venv venv
source venv/bin/activate
uv pip install -r requirements.txt
``` ```
Next, you should configure the bot to your liking ### Config
## Config `$ cp config.json.example config.json`
Even if it seems a bit tedious, I highly recommend going through the entire config and checking if there's something that you would like to customize. ```jsonc
{
"token": "", // Token des Bots
"stations": [ "Berlin Hbf", "Hamburg Hbf", "München Hbf", "Amsterdam Centraal"], // Bahnhöfe, von denen die Anzeigetafelns gelesen werden. Namen kann man auf https://dbf.finalrewind.org/ nachschlagen.
"dbf": "https://dbf.finalrewind.org", // Die DBF Instanz. Normalerweise müsste man hier nichts ändern
"server": , // Discord Server ID
"vc": , // Server VC ID
"random": true, // Random Zug aus der Anzeigetafel (true) oder erster Zug, der angezeigt wird (false)
"emojis": true, // Emoji Namen beim Channel-Namen (true) oder nicht (false),
"formatting": "┇", // VC Name. Davor steht der Emoji, danach der Zug.
"announcemenents": true, // Announcements zur aktuellen Zugreise im VC-Kanal ja (true) oder nein (false)
"voice_announcements": [ // siehe weitere config erklärungen in der README, benötigt extra setup
{
"enabled": false,
"stations": {
"general": ""
}
}
],
"blacklist": [] // Blacklist für bestimmte Zug-Typen
}
I added an [example config](config.json.example). Rename `config.json.example` to `config.json` and fill it out. ```
### Config explanations <details>
<summary>weitere Config Erklärungen</summary>
#### discord #### dbf:
Falls, aus irgendwelchem Gründen, man nicht die [offizielle DBF Instanz](https://dbf.finalrewind.org) nutzen möchte, hat man die Möglichkeiten seine eigene zu hosten. Instruction dazu gibts auf dem [zuständigen Repo](https://github.com/derf/db-fakedisplay). Dafür kann man das Feld in der config mit der eigenen URL austauschen.
- `"token"` is for the discord bot token. You should have copied it earlier #### random:
Bei kleineren Bahnhöfen stehen an den Anzeigetafeln öfters die Züge lange vor Abfahrt da, weil sonst der Bahnhof leer steht. Dadurch wird auch der Name des VC sehr lange gleich bleiben.
Sollte man `random = false` setzen, würde immer der erste Zug an der Anzeigetafel genommen werden, welcher auch der wäre welcher am frühesten losfährt. Wenn man viele Bahnhöfe hat besteht darin keine Gefahr.
- `"server"` and `"vc"` are the ID's for the server and the voice chat that the bot should use. Follow [this guide](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID) to get them. Wenn man nur einen Bahnhof hat ist es stark empfohlen random zu nutzen. Sonst könnte der Bot bei unvollständigen Einträgen in einer Schleife immer wieder vergeblich den selben unvollständigen Zug probieren.
- `"lang"` is for the language that the bot should use within discord. The relevant language files are located in [src/data/locales/](src/data/locales). Current available languages are english ("en") and german ("de"). Feel free to add your language if you miss it! #### announcements:
Der Bot kann während der Zugreise Ankündigungen in den Textkanal vom Sprachkanal schicken. Das würde er aber auch nur machen wenn sich dort mindestens eine Person aufhält. Aktuelle Announcements:
- 3-5 Min vor Umstieg gibt es eine Ankündigung dafür
- Info Embed bei Umstieg
- voice_announcements
- `"formatting"` and `"emojis"` are for the channel name. Set `emojis` to `False` and `"formatting"` to `null` if you just want the connection as the channel's name without any decor. #### voice_announcements:
⚠️ Announcements muss aktiviert sein
#### connections Der Bot kann 3-5 Min vor Umstieg den VC joinen und eine Audiodatei abspielen. Dafür braucht der Bot ffmpeg.
##### stations ```sh
You can add your desired stations that the bot should search connections from there. The bot will choose one of the stations at random. $ sudo apt install ffmpeg # Debian/Ubuntu
The bot will also search for similar named stations if it couldn't find an exact match to your input. This is really handy if you'd just like to ride around in a city with various stations. For example, if you just input "Amsterdam" as a station, the bot would randomly choose one of these stations: $ brew install ffmpeg # macOS (brew)
```
##### Voice_announcements config:
```json ```json
"Amsterdam": [ "voice_announcements": [
"Amsterdam Zuid", {
"Amsterdam RAI", "enabled": true,
"Amsterdam Amstel", "stations": {
"Amsterdam, Gein", "general": "general.aac",
"Amsterdam Centraal", "Hannover": "hannover.aac"
"Amsterdam, Noord", }
"Amsterdam, Rokin", }
"Amsterdam Lelylaan", ],
"Amsterdam Sloterdijk"
]
``` ```
The bot will also warn you if it couldn't find a station with the exact name as your input: Audiodateien werden in [src/data/announcements](src/data/announcements) platziert. In der Config wird der Name der Station (z.B. Hannover) zu dem Namen der Audiodatei (z.B. hannover.aac) zugewiesen. Die Audiodatei braucht keinen Path.
``` Der Eintrag `general` meint die allgemeine Audiodatei, welche bei jeder Endstation (mit Ausnahme der zugewiesenen) spielt. Sollte man nur Audios bei zugewiesenen Endstationen abspielen wollen kann man `general` leerlassen, der bot skipped das ganze dann:
22:44:46: INFO: No station associated as 'Amsterdam', choosing random from similar named stations
```
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
```sh
$ python run main.py stations
```
Example output:
```json ```json
"stations": { "stations": {
"Amsterdam": [ "general": ""
"Amsterdam Zuid", }
"Amsterdam RAI", ```
"Amsterdam Amstel",
"Amsterdam, Gein", Es gibt außerdem die Möglichkeit, mehrere Audiodateien zu einer Endstation durch Listen zuzuweisen. Der Bot sucht sich dann jedes mal wenn er den vc joined eine davon aus.
"Amsterdam Centraal",
"Amsterdam, Noord", ```json
"Amsterdam, Rokin", "stations": {
"Amsterdam Lelylaan", "general": ["general_1.aac", "general_2.aac"]
"Amsterdam Sloterdijk" }
], ```
"Berlin": [
"Berlin Hbf", #### blacklist:
"Berlin ZOB", Die Blacklist ist dafür gedacht, ganze Zugtypen zu ignorieren. Beispielsweise möchte man, dass der Bot keine ICE's, keine NightJets und keine European Sleepers auswählt, da diese sehr lange Strecken fahren und der VC somit lange unverändert bleibt:
"Berlin Ostbf", ```json
"Berlin-Spandau", {
"Berlin Südkreuz", "blacklist": [
"U Rudow (Berlin)", "ICE",
"S Buch (Berlin)", "NJ",
"U Hönow (Berlin)" "ES"
] ]
} }
``` ```
The tool will also ask if it should save a .json file with more informations for every similar station. If you're unsure about what which station is, then it can be really helpful! It would give you data like which types of transports arrive at every similar station, in which country they are and also their coordinates. Die Namen der einzelnen Zugtypen kann im Footer von `/info` oder im Terminal log erfahren.
If you choose to generate the json, then you'll find the file as `stations.json` in the same directory as `main.py` #### src/data
Es kann vorkommen, dass während dem `/info` Befehl das Logo und die Farbe des Bahnuntermehns fehlt.
##### IDs ![Beispiel für fehlende Daten](.github/info_fehlende_daten.png)
For every station inside of the `stations.json` you'll find an ID. You can add that ID to `"IDs"` to really specify that you would like to use THAT station, and not a different one.
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. Die zugehörigen Daten lassen sich innerhalb [src/data/operators.py](src/data/operators.py) ergänzen. Der Aufbau dabei sollte selbsterklärend sein, dennoch habe ich eine kleine Beschreibung in die Datei hinzugefügt. Bei Änderungen sind PR's willkommen.
##### blacklist Emojis für die Formattierung werden dynamisch anhand des Zugtypens gepulled. Dabei wird zwischen Nahverkehr und Fernverkehr unterschieden. Bei einem Zugtyp, welcher zu keiner der Kategorie assigned ist, wird ein Fallback Emoji eingesetzt. Sollte man einen Zugtypen hinzufügen wollen oder die Emojis ändern/deaktivieren wollen ist dies in [src/data/emojis.py](src/data/emojis.py) möglich. Die Namen der einzelnen Zugtypen kann im Footer von `/info` oder im Terminal log erfahren.
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)
```sh
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 Den Status, den sich der Bot alle 5 Minuten random auswählt, kann man in [src/data/status.py](src/data/status.py) anpassen.
##### min_duration, max_duration, max_wait_time, timezone </details>
- `min_duration` is the minimal duration of the connection in minutes. It is HIGHLY recommended to set it to atleast 10, as discord only allows to change the name of a voice chat twice every 10 minutes. ### Running
- `max_duration` is the maximun duration of the connection in minutes. Set to `null` to disable
- `max_wait_time` is the maximun wait time for a connection in hours. Don't set that one too low, as the bot may have some issues finding a suitable connection at night, or at stations that aren't frequently used. Set to `null` to disable
- `timezone` is timezone in the IANA timezone format. You can [look it up here](https://www.addevent.com/c/documentation/tools/time-zone-lookup)
##### announcements
##### `text_announcements:`
The bot can send a text announcement in the voice chat at the start/end of a trip.
At the end of a trip, it would send this embed:
![end_of_trip](.github/end_of_trip_announcement.png)
It will also send the `/info` embed at the start of a new connection with informations about your new trip.
To reduce spam, the bot will only send announcements if someone is in the voice chat
##### `voice_announcements:`
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.
#### 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
## Running
After you've set everything up, you're ready to start the bot!
```sh ```sh
$ python run main.py # Python
python main.py
# ODER
python3 main.py
# uv
uv run main.py
``` ```
## src/data ## Bekannte Bugs
[src/data](src/data/) contains a few files/directories which may be interesting to customize. #### Stuttgart in Berlin
Ich weiß nicht ganz wieso, aber die API vertauscht manchmal die S-Bahn von Berlin mit der S-Bahn von Stuttgart. Es scheint eher ein API-Issue zu sein, weswegen ich da leider mit dem Bot nicht viel ändern kann.
Der Bug führt dazu, dass bei manchen S-Bahn Verbindungen `DB Regio AG S-Bahn Stuttgart` als Betreiber der Berlin S-Bahn angezeigt wird. Außerdem gibt die API dem Bot die Ankunftszeiten einer S-Bahn Verbindung von Stuttgart wieder, während die Route von der aus Berlin stammt (Die Route und die Ankunftszeiten werden von zwei verschiedenen Endpoints gepulled: Route: `{dbf}/Berlin%20Hbf.json`, Ankunftszeit: `{dbf}/z/S%20{ID}/Berlin Hbf.json`).
Falls das einem zu sehr stört kann man S-Bahns auf die Blacklist packen.
### [emojis.py](src/data/emojis.py) ```json
if `emojis` is enabled in the config, the bot will use that file to determine the right emoji for the voice chats name according to the current mode. You can add missing modes there or customize the emojis {
"blacklist": [
"S "
]
}
### [operators.py](src/data/operators.py) ```
Metadata for the `/info` embed. You can assign an agency a logo, color and (optionally) some slogans. The agency name is visible in the console log and the `/info` embed.
If multiple agencys should use the same metadata, take a look at OPERATOR_ALIASES. #### Nahreisezug
Der Bot empfängt durch die dbf API ein Operator Field, wo der Betreiber des Züges angezeigt wird. Dadurch kann im `/info` Embed das Logo durch [src/data/operators.py](src/data/operators.py) zugewiesen werden. Manchmal schmeißt die API aber als Operator "Nahreisezug" aus.
You don't have to restart the bot after editing this file. ![Beispiel für Nahreisezug](.github/info_nahreisezug.png)
### [locales/](src/data/locales/) Wenn mehrere Betreiber angezeigt werden (bspw `["SBB", "Nahreisezug"]`) versucht der Bot immer, den Embed den bekannten Betreiber (in dem Fall SBB) zuzuweisen. Sollte allerdings nur Nahreisezug angezeigt werden, kann damit nicht gearbeitet werden, wodurch die Fallback Metadaten genutzt werden.
Covered in [locales](#locales)
### [announcements/](src/data/announcements/) Die Fallback Metadaten können in [src/data/operators.py](src/data/operators.py) angepasst werden.
Covered in [voice announcements](#voice)
### [announcements](src/data/announcements/)
Assets (such as images) which the bot uses. You shouldn't have to change anything there
+13 -26
View File
@@ -1,33 +1,20 @@
{ {
"discord": {
"token": "", "token": "",
"stations": ["Hamburg Hbf", "München Hbf", "Köln Hbf", "Amsterdam Centraal"],
"dbf": "https://dbf.finalrewind.org",
"server": , "server": ,
"vc": , "vc": ,
"lang": "de", "random": true,
"emojis": true,
"formatting": "┇", "formatting": "┇",
"emojis": true "announcements": true,
}, "voice_announcements: [
"connections": { {
"stations": [ "enabled": false,
"Berlin Hauptbahnhof", "stations": {
"Amsterdam", "general": ""
"Helsinki"
],
"IDs: []
"blacklist": [
"OTHER",
"RIDE_SHARING"
],
"min_duration": 10,
"max_duration": null,
"max_wait_time": null,
"timezone": "Europe/Berlin"
},
"announcements": {
"text_announcements": true,
"voice_announcements": false
},
"http": {
"user_agent": "Gleiswechsel-Discord-Bot"
} }
} }
]
"blacklist": []
}
+26 -29
View File
@@ -1,43 +1,40 @@
import discord import discord
import sys import random
from discord.ext import tasks
from src.utils import logger
from src.config import config from src.config import config
from src.dc.handlers import rename_vc from src.handlers import rename_vc
from src.dc.helpers import validate_channel from src.commands import setup_commands
from src.dc.commands import setup_commands from src.embeds import build_error_embed
from src.api.transitous import check_stations from src.utils import logger
from src.data.status import discord_status
bot = discord.Bot(intents=discord.Intents.all()) bot = discord.Bot(intents=discord.Intents.all())
setup_commands(bot=bot) setup_commands(bot)
@tasks.loop(minutes=5)
async def change_status():
status = random.choice(discord_status)
await bot.change_presence(activity=discord.Game(name=f"{status} • /info"))
_bot_initialized = False _bot_initialized = False
if len(sys.argv) > 1:
if sys.argv[1] == "stations":
check_stations()
sys.exit(0)
@bot.event @bot.event
async def on_ready(): async def on_ready():
global _bot_initialized global _bot_initialized
print(f"{bot.user} ist online")
if not change_status.is_running():
change_status.start()
if not _bot_initialized: if not _bot_initialized:
logger(f"{bot.user} is online")
_bot_initialized = True _bot_initialized = True
await rename_vc(bot)
else:
logger("Discord Reconnect, laufende Fahrt bleibt unangetastet")
server_id = config.discord.server @bot.event
server_vc_id = config.discord.vc async def on_application_command_error(ctx, error):
channel = validate_channel(bot=bot, server_id=server_id, channel_id=server_vc_id) embed = build_error_embed(f"Ein Fehler ist aufgetreten: {error}")
await rename_vc(bot, voice_channel=channel) await ctx.respond(embed=embed)
try: try:
bot.run(config.discord.token) bot.run(config['token'])
except: except:
logger("An error occured while parsing the token", "fatal") logger("Fehler beim parsen des token", "fatal")
'''
TODO
- discord status
- multi language support
- README
'''
-1
View File
@@ -13,7 +13,6 @@ 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
pyyaml==6.0.3
pycparser==3.0 pycparser==3.0
pynacl==1.6.2 pynacl==1.6.2
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
-297
View File
@@ -1,297 +0,0 @@
import requests
import random
import json
from datetime import datetime, timezone
from src.utils import logger, get_train_name, validate_connection, parse_iso
from src.config import config
from src.lang.locales import lang
station_names = config.connections.stations
station_IDs = config.connections.IDs
blacklist = config.connections.blacklist
long_name_lang = lang.channel.long_name
user_agent = config.http.user_agent
headers = {
"User-Agent": f"{user_agent}"
}
endpoint = "https://api.transitous.org"
def check_stations():
logger(f"Testing {len(station_names)} stations...")
all_stations_output = {}
minimal_overview = {}
for station in station_names:
req = f"{endpoint}/api/v1/geocode"
try:
response = requests.get(req, params={"text": station}, headers=headers)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(f"An error occured while checking for station {station}: {e}")
continue
stops_dict = {}
aliases_list = []
exact_match = False
for entry in data:
if entry.get("type") != "STOP":
continue
station_id = entry.get("id")
stop_name = entry.get("name")
coords = f"{entry.get("lat")}, {entry.get("lon")}"
modes = entry.get("modes")
stop_details = {
"tz": entry.get("tz"),
"country": entry.get("country"),
"coords": coords,
"id": station_id,
"modes": modes,
}
if stop_name:
stops_dict[stop_name] = stop_details
aliases_list.append(stop_name)
if stop_name == station:
if not exact_match:
exact_match = True
if not stops_dict:
logger("Failed to grab ID from 'search_name'", "error")
continue
all_stations_output[station] = {
"exact_match": exact_match,
"associated": stops_dict
}
minimal_overview[station] = {
"exact_match": exact_match,
"names": aliases_list
}
logger(json.dumps(minimal_overview, indent=4, ensure_ascii=False))
print(f"\nIf you want, I can save a more detailed version directly as a json file to disk.")
print("The json would provide informations like coords, country and transport modes that are from every specific associated station.")
prompt = input("This would help you to identify the associated stations more accurately (y/n): ")
if prompt == "y" or prompt == "yes":
with open('stations.json', 'w') as f:
json.dump(all_stations_output, f, indent=4, ensure_ascii=False)
logger("stations.json generated")
else:
logger("okay :(")
def get_random_stop_id() -> str | None:
stations = {}
if len(station_names) > 0:
for entry in station_names:
if entry != "":
stations[entry] = "name"
if len(station_IDs) > 0:
for entry in station_IDs:
if entry != "":
stations[entry] = "ID"
if not stations:
logger("Both stations and IDs are empty...", "FATAL")
assigned_station = random.choice(list(stations.keys()))
if stations[assigned_station] == "ID":
return assigned_station
req = f"{endpoint}/api/v1/geocode"
try:
response = requests.get(req, params={"text": assigned_station}, headers=headers)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(f"An error occured while searching for a connection: {e}", "error")
return None
stop_ids = {}
for entry in data:
if entry.get("type") != "STOP":
continue
stop_name = entry.get("name")
stop_id = entry.get("id", None)
stop_ids[stop_id] = stop_name
if stop_name == assigned_station:
stop_ids.clear()
stop_ids[stop_id] = stop_name
break
if stop_ids is None:
logger(f"Failed to grab ID from '{assigned_station}'", "error")
return None
stop_ids_list = list(stop_ids.keys())
if len(stop_ids_list) > 1:
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")
chosen_stop_id, chosen_station = random.choice(list(stop_ids.items()))
return chosen_stop_id
def get_random_connection(stop_id: str) -> str | None:
if stop_id is None:
return None
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
cursor = None
max_pages = 5
min_results = 5
trip_ids = []
all_stop_times = []
for _ in range(max_pages):
params = {
"stopId": stop_id,
"n": 20,
"time": now,
}
if cursor:
params["pageCursor"] = cursor
try:
response = requests.get(f"{endpoint}/api/v5/stoptimes", params=params, headers=headers)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(e, "Error")
break
stop_times = data.get("stopTimes", [])
all_stop_times.extend(stop_times)
for entry in stop_times:
trip_id = entry["tripId"]
if entry["mode"] in blacklist:
continue
else:
trip_ids.append(entry["tripId"])
if len(trip_ids) >= min_results:
break
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 {
"trip_id": trip_id,
"from_station": from_station
}
def get_trip_details(random_connection: dict | None) -> dict | None:
if random_connection is None:
return None
req = f"{endpoint}/api/v2/trip"
try:
response = requests.get(req, params={"tripId": random_connection["trip_id"]}, headers=headers)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(f"An error occured while trying to get the route details: {e}", "error")
return None
legs = data["legs"][0]
end_time = legs["endTime"]
from_station = random_connection["from_station"]
display_name = legs["displayName"]
train_from = legs["from"]["name"]
goes_to = legs["to"]["name"]
start_time = legs["startTime"]
mode = legs["mode"]
if goes_to == train_from:
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
arrival_dt = parse_iso(end_time)
departure_dt = parse_iso(start_time)
train_name = get_train_name(display_name, mode) # used by lang
if train_from == from_station:
long_name = long_name_lang.train_from()
else:
long_name = long_name_lang.train_via()
trip_details = {
"long_name": long_name,
"short_name": display_name,
"station": from_station,
"from": train_from,
"to": goes_to,
"agency": legs["agencyName"],
"route_color": legs.get("routeColor"),
"departure": departure_dt.strftime("%H:%M"),
"departure_dt": departure_dt,
"arrival": arrival_dt.strftime("%H:%M"),
"arrival_dt": arrival_dt,
"mode": mode,
"stops": {}
}
train_from_importance = legs["from"]["importance"]
trip_details["stops"][train_from] = {
"arrival": departure_dt,
"importance": train_from_importance
}
departure_time_iso = start_time
for stop in legs["intermediateStops"]:
stop_arrival_dt = parse_iso(stop["arrival"])
stop_importance = stop["importance"]
stop_details = {
"arrival": stop_arrival_dt,
"importance": stop_importance
}
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")
train_to_importance = legs["to"]["importance"]
trip_details["stops"][goes_to] = {
"arrival": arrival_dt,
"importance": train_to_importance
}
valid = validate_connection(start_time, end_time, departure_time_iso)
if not valid:
return None
return trip_details
+12
View File
@@ -0,0 +1,12 @@
import discord
from discord.ext import commands
from src.embeds import build_info_embed, build_error_embed
def setup_commands(bot: discord.Bot):
@bot.slash_command(description="Informationen über die aktuelle Fahrt")
async def info(ctx):
embed = build_info_embed()
if embed is None:
await build_error_embed("Noch keine Verbindung gesetzt.")
return
await ctx.respond(embed=embed)
+1 -48
View File
@@ -1,51 +1,4 @@
import json import json
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class DiscordConfig:
token: str
server: int
vc: int
lang: str
formatting: str
emojis: bool
@dataclass
class ConnectionsConfig:
stations: list[str]
IDs: list[str]
blacklist: list[str]
min_duration: int
timezone: str
max_wait_time: Optional[int] = None
max_duration: Optional[int] = None
@dataclass
class AnnouncementConfig:
text_announcements: bool
voice_announcements: bool
@dataclass
class HttpConfig:
user_agent: str
@dataclass
class Config:
discord: DiscordConfig
connections: ConnectionsConfig
announcements: AnnouncementConfig
http: HttpConfig
def _load_config() -> Config:
with open("config.json", "r") as file: with open("config.json", "r") as file:
raw = json.load(file) config = json.load(file)
return Config(
discord=DiscordConfig(**raw["discord"]),
connections=ConnectionsConfig(**raw["connections"]),
announcements=AnnouncementConfig(**raw["announcements"]),
http=HttpConfig(**raw["http"]),
)
config = _load_config()
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
+16 -10
View File
@@ -1,12 +1,18 @@
emoji_list = { emoji_list = {
"Fallback": "💺", "Fallback": "🚉",
"BUS": "🚎", "Nahverkehr": "🚃",
"COACH": "🚎", "Höchstgeschwindigkeit": "🚄",
"TRAM": "🚈", "Bus": "🚎"
"REGIONAL_RAIL": "🚊", }
"HIGHSPEED_RAIL": "🚅",
"LONG_DISTANCE": "🚅", train_types = { # Unvollständig!
"METRO": "🚇", "ICE": emoji_list["Höchstgeschwindigkeit"],
"SUBWAY": "🚇", "TGV": emoji_list["Höchstgeschwindigkeit"],
"FERRY": "🚢"
"RE": emoji_list["Nahverkehr"],
"RB": emoji_list["Nahverkehr"],
"ARV": emoji_list["Nahverkehr"],
"BRB": emoji_list["Nahverkehr"],
"Bus": emoji_list["Bus"]
} }
-18
View File
@@ -1,18 +0,0 @@
channel:
long_name:
train_from: "{train_name} nach {goes_to} von {train_from}"
train_via: "{train_name} nach {goes_to} über {from_station}"
embeds:
footer:
notice: "Daten großzügig bereitgestellt von https://transitous.org"
info:
description: "Abfahrt von {station} um {departure}. Ankunft um {arrival}"
via: "Über"
"via_and": "und"
"next_stop": "Nächster Halt"
announcement:
title: "Informationen zu ihrer Fahrt"
"end_of_connection":
message: "Sehr geehrte Fahrgäste,\nIn wenigen Minuten erreichen wir {destination}. Unsere Reise endet dort\n\nWir wünschen Ihnen eine angenehme Weiterreise.\n\nVielen Dank für ihr Vertrauen und auf Wiedersehen."
rich_presence:
arrival: "Ankunft um {arrival} • /info"
-18
View File
@@ -1,18 +0,0 @@
channel:
long_name:
train_from: "{train_name} to {goes_to} from {train_from}"
train_via: "{train_name} to {goes_to} via {from_station}"
embeds:
footer:
notice: "Data provided by https://transitous.org"
info:
description: "Departure from {station} at {departure}. Arrival by {arrival}"
via: "via"
"via_and": "and"
"next_stop": "Next stop"
announcement:
title: "Information about your trip"
"end_of_connection":
message: "Dear Passengers,\nwe will be arriving at {destination} in a few minutes. Our journey ends there.\n\nWe wish you a pleasant onward journey.\n\nThank you for your patronage, and goodbye."
rich_presence:
arrival: "Arrival by {arrival} • /info"
+88 -108
View File
@@ -1,120 +1,107 @@
''''
Die Datei gibt dem Embed zugehörige Metadaten der Zugbetreiber. Identifiziert wird dabei durch die genauen Namen der Unternehmen.
Diese werden sowohl im /info Embed als auch in der Konsole angezeigt.
| Feld | Optional | Wert | Beschreibung |
|---------|----------|-------|-----------------------------------------------------------------------|
| Unknown | Ja | Bool | Eigentlich nur für fallback relevant, sollte man weglassen |
| Logo | Nein | URL | Das Logo in der Ecke des Embeds. Am besten PNG, SVG geht leider nicht |
| Color | Nein | HEX | HEX Color Code des Streifen vom Embed, muss mit 0x anfangen |
| Slogan | Ja | Liste | Zufälliger Text aus der Liste im Footer |
Sollte es dazu kommen, dass mehrere Anbieter die selben Metadaten nutzen sollen, kann man einen Eintrag für alle in OPERATORS setzen und
die zugehörigen Anbieternamen in OPERATOR_ALIASES auf den Eintrag lenken lassen. db_bawü ist dafür ein gutes Beispiel.
Nachdem diese Datei editiert wurde muss der Bot nicht neugestartet werden.
'''
OPERATORS = { OPERATORS = {
"Abellio Rail Mitteldeutschland GmbH": { "fallback": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Abellio_logo.svg/1920px-Abellio_logo.svg.png", "unknown": True,
"color": 0xD7002E "logo": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQCVeC5E0mNNBKyQftQaFMzxIVkbDvEnSzWv07h_c8PdA&s=10",
}, "color": 0xFFFFFF
"Agilis": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Agilis_Logo.svg/960px-Agilis_Logo.svg.png",
"color": 0xCAE15B
},
"Arverio Bayern": {
"logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528006572805062776/Arverio_Avi_Bayern_blau_RGB.png?ex=6a5cba83&is=6a5b6903&hm=5781ae6c372c92ab4f52409c6fc91e5014ac34ccf24db665ade052e8135bdde0&animated=true",
"color": 0x0083BE
},
"Berliner Verkehrsbetriebe": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/BVG_Logo_07.2021.svg/960px-BVG_Logo_07.2021.svg.png",
"color": 0xEFD13C
},
"DSB": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/DSB_company_logo.svg/960px-DSB_company_logo.svg.png",
"color": 0xB22B32
},
"enno": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Enno_logo.svg/960px-Enno_logo.svg.png",
"color": 0x88216F
},
"Erfurter Bahn GmbH": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/Erfurter_Bahn_logo.svg/960px-Erfurter_Bahn_logo.svg.png",
"color": 0x009133
},
"eurobahn": {
"logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528405390260179146/Eurpnajm.png?ex=6a5e2df1&is=6a5cdc71&hm=d73fc3458f59f8942a5a0f51311308b12567b35b55b64aab0726bf6be92b3b0e&animated=true",
"color": 0x005a9b
},
"European Sleeper": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/European_Sleeper_Logo.svg/960px-European_Sleeper_Logo.svg.png",
"color": 0xEB4A27
},
"Flixbus": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Flixbus_201x_logo.svg/1280px-Flixbus_201x_logo.svg.png",
"color": 0x8CD541
},
"GoVolta": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/GoVolta_logo.svg/1280px-GoVolta_logo.svg.png",
"color": 0x0B70F6
},
"GVB": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/BSicon_LOGO_GVB.svg/960px-BSicon_LOGO_GVB.svg.png",
"color": 0x2B62AF
},
"Nordbahn Eisenbahngesellschaft": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/b/b5/Logo_Nordbahn_NAH.SH_Blau_positiv_final.png",
"color": 0x1A2848
},
"NS": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Logo_NS.svg/960px-Logo_NS.svg.png",
"color": 0x00337F,
"slogan": ["Goed op weg", "Welkom in de trein van morgen", "Veilig, Vlug, Voordelig", "we haben een serious probleem", "Neuken in de keuken"]
},
"Ostdeutsche Eisenbahn GmbH": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/ODEG-Logo_Neu.svg/960px-ODEG-Logo_Neu.svg.png",
"color": 0x00745C
},
"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",
"color": 0x61A731
},
"S-Bahn Hannover (Transdev)": {
"logo": "https://cdn.discordapp.com/attachments/1526274436523888732/1528470315234234520/sbahn.png?ex=6a5e6a68&is=6a5d18e8&hm=c9f630c6f0c646eafeccef9b4d591ce8fcf8856b2b45f14e72fbf25e32edb017&animated=true",
"color": 0x1A4389
},
"SBB": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/SBB_logo_simplified.svg/960px-SBB_logo_simplified.svg.png",
"color": 0xEB0000
},
"SNCF": {
"logo": "https://upload.wikimedia.org/wikipedia/en/thumb/f/f4/Sncf-logo.svg/960px-Sncf-logo.svg.png",
"color": 0x812B6D
},
"Tallink Grupp AS": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Tallink_logo.svg/330px-Tallink_logo.svg.png",
"color": 0x225197
},
"Vr": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/Vr_Logo.png/330px-Vr_Logo.png",
"color": 0x00B451
},
"Westbahn Management GmbH": {
"logo": "https://corporate.westbahn.at/uploads/Logos/westbahn2025-logo-signet-small.png",
"color": 0x1D4A83
},
"WestfalenBahn": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Westfalenbahn_logo.svg/330px-Westfalenbahn_logo.svg.png",
"color": 0x48A1DD
}, },
"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",
"color": 0xEC0016, "color": 0xEC0016,
"slogan": ["Senk ju vor träwelling wis Deutsche Bahn.", "Bitte beachten Sie die umgekehrte Wagenreihung.", "Zurückbleiben bitte!", "Alle reden vom Wetter. Wir nicht.", "Die Bahn macht mobil.", "Grün abgefahren"] "slogan": ["Senk ju vor träwelling wis Deutsche Bahn.", "Bitte beachten Sie die umgekehrte Wagenreihung.", "Zurückbleiben bitte!", "Alle reden vom Wetter. Wir nicht.", "Die Bahn macht mobil.", "Grün abgefahren"]
}, },
"db_bayern": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Bahnland_Bayern_Logo_2021.svg/500px-Bahnland_Bayern_Logo_2021.svg.png",
"color": 0x0095DB
},
"db_bawü": { "db_bawü": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/Bwegt_Logo.svg/960px-Bwegt_Logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/Bwegt_Logo.svg/960px-Bwegt_Logo.svg.png",
"color": 0xFFBF34, "color": 0xFFBF34,
"slogan": ["Nett hier", "Aber waren Sie schon mal in Baden-Württemberg?", "Bwegt euch!"] "slogan": ["Nett hier", "Aber waren Sie schon mal in Baden-Württemberg?", "Bwegt euch!"]
}, },
"db_bayern": { "Ostdeutsche Eisenbahn GmbH": {
"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/f/f3/ODEG-Logo_Neu.svg/960px-ODEG-Logo_Neu.svg.png",
"color": 0x0095DB "color": 0x00745C
}, },
"fallback": { "Nederlandse Spoorwegen": {
"logo": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQCVeC5E0mNNBKyQftQaFMzxIVkbDvEnSzWv07h_c8PdA&s=10", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Logo_NS.svg/960px-Logo_NS.svg.png",
"color": 0xFFFFFF "color": 0X00337F,
"slogan": ["Goed op weg", "Welkom in de trein van morgen", "Veilig, Vlug, Voordelig", "we haben een serious probleem", "Neuken in de keuken"]
},
"eurobahn": {
"logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528405390260179146/Eurpnajm.png?ex=6a5e2df1&is=6a5cdc71&hm=d73fc3458f59f8942a5a0f51311308b12567b35b55b64aab0726bf6be92b3b0e&animated=true",
"color": 0x005a9b,
},
"Arverio Bayern": {
"logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528006572805062776/Arverio_Avi_Bayern_blau_RGB.png?ex=6a5cba83&is=6a5b6903&hm=5781ae6c372c92ab4f52409c6fc91e5014ac34ccf24db665ade052e8135bdde0&animated=true", # alternative nötig!
"color": 0x0083BE
},
"Abellio Rail Mitteldeutschland GmbH": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Abellio_logo.svg/1920px-Abellio_logo.svg.png",
"color": 0xD7002E
},
"SBB": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/SBB_logo_simplified.svg/960px-SBB_logo_simplified.svg.png",
"color": 0xEB0000
},
"Nordbahn Eisenbahngesellschaft": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/b/b5/Logo_Nordbahn_NAH.SH_Blau_positiv_final.png",
"color": 0x1A2848
},
"Erfurter Bahn GmbH": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/Erfurter_Bahn_logo.svg/960px-Erfurter_Bahn_logo.svg.png",
"color": 0x009133
},
"S-Bahn Hannover (Transdev)": {
"logo": "https://cdn.discordapp.com/attachments/1526274436523888732/1528470315234234520/sbahn.png?ex=6a5e6a68&is=6a5d18e8&hm=c9f630c6f0c646eafeccef9b4d591ce8fcf8856b2b45f14e72fbf25e32edb017&animated=true",
"color": 0x1A4389
},
"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",
"color": 0x61A731
},
"Westbahn Management GmbH": {
"logo": "https://corporate.westbahn.at/uploads/Logos/westbahn2025-logo-signet-small.png",
"color": 0x1D4A83
},
"European Sleeper": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/European_Sleeper_Logo.svg/960px-European_Sleeper_Logo.svg.png",
"color": 0xEB4A27
},
"GoVolta": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/GoVolta_logo.svg/1280px-GoVolta_logo.svg.png",
"color": 0x0B70F6
},
"SNCF": {
"logo": "https://upload.wikimedia.org/wikipedia/en/thumb/f/f4/Sncf-logo.svg/960px-Sncf-logo.svg.png",
"color": 0x812B6D
},
"Agilis": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Agilis_Logo.svg/960px-Agilis_Logo.svg.png",
"color": 0xCAE15B
},
"enno": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Enno_logo.svg/960px-Enno_logo.svg.png",
"color": 0x88216F
} }
} }
OPERATOR_ALIASES = { OPERATOR_ALIASES = {
"Arverio Bayern GmbH": OPERATORS["Arverio Bayern"],
"DB Regio AG Baden-Württemberg": OPERATORS["db_bawü"], "DB Regio AG Baden-Württemberg": OPERATORS["db_bawü"],
"DB Regio Stuttgart GmbH": OPERATORS["db_bawü"], "DB Regio Stuttgart GmbH": OPERATORS["db_bawü"],
"Arverio Baden-Württemberg": OPERATORS["db_bawü"], "Arverio Baden-Württemberg": OPERATORS["db_bawü"],
@@ -122,17 +109,10 @@ OPERATOR_ALIASES = {
"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 NRW": OPERATORS["db_allgemein"], "DB Regio AG NRW": OPERATORS["db_allgemein"],
"DB Regio AG Nord": 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"],
"DB Regio AG Nord": OPERATORS["db_allgemein"],
"DB Regio AG Mitte": OPERATORS["db_allgemein"], "DB Regio AG Mitte": OPERATORS["db_allgemein"],
"NS International": OPERATORS["NS"],
"SBB GmbH": OPERATORS["SBB"], "SBB GmbH": OPERATORS["SBB"],
"Schweizerische Bundesbahnen SBB": OPERATORS["SBB"], "Schweizerische Bundesbahnen SBB": OPERATORS["SBB"],
"Dänische Staatsbahnen": OPERATORS["DSB"],
"VR": OPERATORS["Vr"],
"FlixBus-gb": OPERATORS["Flixbus"]
} }
+16
View File
@@ -0,0 +1,16 @@
discord_status = [
"Tschu-Tschu!",
"Heute ca. 15 Minuten später",
"Heute ohne Halt in Hamburg-Altona",
"Störung an der Weiche",
"Heute ohne Bordbistro",
"SEV ist eingerichtet",
"Gleiswechsel!",
"Zug entgleist :(",
"Die Bundespolizei ist informiert",
"Dieser Zug endet hier",
"Außerplanmäßiger Halt in Oberhausen Hbf",
"Verspätung aus vorheriger Fahrt",
"Der Zug hat keine Bremsen",
"Heute ohne Klimatisierung",
]
-7
View File
@@ -1,7 +0,0 @@
import discord
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())
-83
View File
@@ -1,83 +0,0 @@
import discord
import random
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.lang.locales import lang
lang_embed = lang.embeds
def build_embed_footer(mode: str, slogans):
footer_notice = f"{lang.embeds.footer.notice()} • mode: {mode}"
icon = "https://raw.githubusercontent.com/kaaninchen/Gleiswechsel/refs/heads/main/src/data/assets/transitous-logo.png"
if slogans is not None:
footer_text = f"{random.choice(slogans)}{footer_notice}"
else:
footer_text = footer_notice
return {
"text": footer_text,
"icon": icon
}
def build_info_embed() -> discord.Embed:
from src.dc.handlers import trip
station = trip["station"]
agency = trip["agency"]
metadata = get_operator_metadata(agency, trip["route_color"], trip["mode"])
departure = format_timestamp_to_dc(trip["departure"])
arrival = format_timestamp_to_dc(trip["arrival"])
embed = discord.Embed(
title = trip["long_name"],
description=lang_embed.info.description(),
color = metadata["color"]
)
stops = trip["stops"]
next_stop = get_next_station(trip["stops"], trip["from"])
next_stop_station = None
if next_stop:
next_stop_station = next_stop.get("name")
via = format_via_list(stops, lang_embed.info.via_and())
if via:
embed.add_field(name=lang_embed.info.via(), value=via, inline=False)
if next_stop:
embed.add_field(name=lang_embed.info.next_stop(), value=f"__{next_stop_station}__", inline=False)
route_fields = format_stop_list(stops, next_stop_station)
for field_name, field_value in route_fields:
embed.add_field(name=field_name, value=field_value, inline=False)
footer = build_embed_footer(trip["mode"], metadata["slogans"])
embed.set_footer(text=footer["text"], icon_url=footer["icon"])
embed.set_author(name=agency)
embed.set_thumbnail(url=metadata["logo"])
return embed
def build_announcement_embed(msg):
from src.dc.handlers import trip
agency = trip["agency"]
metadata = get_operator_metadata(agency, trip["route_color"], trip["mode"])
embed = discord.Embed(
title = lang_embed.announcement.title(),
description=msg,
color=metadata["color"]
)
embed.set_author(name=agency)
embed.set_thumbnail(url=metadata["logo"])
footer = build_embed_footer(trip["mode"], metadata.get("slogan"))
embed.set_footer(text=footer["text"], icon_url=footer["icon"])
return embed
-159
View File
@@ -1,159 +0,0 @@
import discord
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.config import config
from src.lang.locales import lang
_scheduled_task: asyncio.Task | None = None
_next_stop_task: asyncio.Task | None = None
channel_lang = lang.channel
async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = False):
global trip, _scheduled_task
if not from_scheduler and _scheduled_task and not _scheduled_task.done():
_scheduled_task.cancel()
attempt = 0
max_attempt = 15
trip = choose_connection()
while trip is None and attempt < max_attempt:
attempt += 1
trip = choose_connection()
if trip is None:
logger(f"Failed to select route after {max_attempt} attempts", "fatal")
return False
arrival = trip["arrival"]
long_name = trip["long_name"]
mode = trip["mode"]
print("-----------------")
logger(f"Transfer: {long_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.set_status(None)
start_next_stop_updates(voice_channel)
logger(f"Updated channel name!")
await bot.change_presence(activity=discord.Game(name=lang.rich_presence.arrival()))
await announcer("transfer", voice_channel)
_scheduled_task = asyncio.create_task(_schedule_next_transfer(bot, trip["arrival_dt"], voice_channel, trip["to"]))
async def announcer(announcement: str, voice_channel: discord.VoiceChannel, destination = None):
from src.dc.embeds import build_info_embed, build_announcement_embed
announcements_enabled = config.announcements.text_announcements
if announcements_enabled:
if len(voice_channel.members) > 0:
match announcement:
case "end_of_connection":
embed = build_announcement_embed(lang.embeds.announcement.end_of_connection.message())
case "transfer":
embed = build_info_embed()
case _:
logger(f"Unknown announcement: {announcement}")
embed = None
if embed:
await voice_channel.send(embed=embed)
async def voice_announcer(station: str, voice_channel: discord.VoiceChannel) -> bool:
sound_path = get_sound_path(station=station)
if sound_path is None:
return False
if voice_channel.guild.voice_client:
logger(f"Already in vc, skipping this announcement to be safe")
return False
logger(f"Joining vc, playing {sound_path}")
connect_task = asyncio.create_task(voice_channel.connect(timeout=15, reconnect=True))
audio_source = discord.FFmpegPCMAudio(sound_path)
vc = await connect_task
loop = asyncio.get_running_loop()
if not vc.is_playing():
def after_playing(error):
if error:
logger(f"Player error: {error}", "error")
loop.create_task(vc.disconnect())
logger("Leaving vc")
vc.play(audio_source, after=after_playing)
return True
async def _schedule_next_transfer(bot: discord.Bot, arrival_dt: datetime, voice_channel: discord.VoiceChannel, destination: str):
now = datetime.now(LOCAL_TZ)
wait_seconds = (arrival_dt - now).total_seconds()
announcement_countdown = random.randrange(180, 300)
if wait_seconds > 0:
remaining = str(timedelta(seconds=wait_seconds))
logger(f"Next transfer in {remaining.split('.')[0]} ({arrival_dt.strftime('%H:%M')})")
if wait_seconds > announcement_countdown:
wait_until_end_announcement = wait_seconds - announcement_countdown
await asyncio.sleep(wait_until_end_announcement)
await announcer("end_of_connection", voice_channel, destination)
await asyncio.sleep(announcement_countdown)
else:
await asyncio.sleep(wait_seconds)
logger("Train arrived, searching for a new connection....")
await rename_vc(bot, voice_channel, from_scheduler=True)
async def _update_next_loop(voice_channel: discord.VoiceChannel):
global trip
try:
if trip is None:
return
now = datetime.now(LOCAL_TZ)
departure_dt = trip["departure_dt"]
if departure_dt > now:
wait_seconds = (departure_dt - now).total_seconds() + 10
await asyncio.sleep(wait_seconds)
while True:
next_stop = get_next_station(trip["stops"], trip["from"])
if next_stop is None:
return
next_stop_str = next_stop["name"]
status_text = f"{lang.embeds.info.next_stop()}: {next_stop_str}"
await voice_channel.set_status(status_text, reason="Next stop status")
if config.announcements.voice_announcements:
if len(voice_channel.members) > 0:
await voice_announcer(next_stop_str, voice_channel)
wait_seconds = (next_stop["arrival"] - datetime.now(LOCAL_TZ)).total_seconds()
if wait_seconds > 0:
await asyncio.sleep(wait_seconds)
except asyncio.CancelledError:
raise
def start_next_stop_updates(voice_channel: discord.VoiceChannel):
global _next_stop_task
if _next_stop_task is not None and not _next_stop_task.done():
_next_stop_task.cancel()
_next_stop_task = asyncio.create_task(_update_next_loop(voice_channel))
-32
View File
@@ -1,32 +0,0 @@
import discord
from datetime import datetime, timedelta
from src.utils import logger
def validate_channel(bot: discord.bot, server_id: int, channel_id: int):
guild = bot.get_guild(server_id)
if guild is None:
logger(f"Couldn't find server with ID '{server_id}', is the bot invited?", "fatal")
return False
channel = guild.get_channel(channel_id)
if not isinstance(channel, discord.VoiceChannel):
logger(f"Couldn't find vc with '{channel_id}'", "fatal")
return False
return channel
def format_timestamp_to_dc(timestr):
parsed_time = datetime.strptime(timestr, "%H:%M")
now = datetime.now()
final_datetime = datetime.now().replace(
hour=parsed_time.hour,
minute=parsed_time.minute,
second=0,
microsecond=0
)
if final_datetime <= now:
final_datetime += timedelta(days=1)
return discord.utils.format_dt(final_datetime, style="t")
+109
View File
@@ -0,0 +1,109 @@
import random
import discord
from datetime import datetime, timedelta
import src.handlers as handlers
from src.config import config
from src.utils import operator_metadata, format_via_list, resolve_operator, logger
def format_timestamp(timestr):
parsed_time = datetime.strptime(timestr, "%H:%M")
now = datetime.now()
final_datetime = datetime.now().replace(
hour=parsed_time.hour,
minute=parsed_time.minute,
second=0,
microsecond=0
)
if final_datetime <= now:
final_datetime += timedelta(days=1)
return discord.utils.format_dt(final_datetime, style="t")
def format_iso_timestamp(isostr):
parsed_time = discord.utils.parse_time(isostr)
return discord.utils.format_dt(parsed_time, style="t")
def build_embed_footer(operator_slogans):
dbf = config.get("dbf", "https://dbf.finalrewind.org")
footer_notice = f"Daten großzügig bereitgestellt von {dbf} • Typ: {handlers.train_type}"
footer_text = (
f"{random.choice(operator_slogans)}{footer_notice}"
if operator_slogans else
footer_notice
)
icon = f"{dbf}/static/icons/icon-96x96.png"
return {
"text": footer_text,
"icon": icon
}
def build_info_embed() -> discord.Embed | None:
conn = handlers.current
info = handlers.train_info
if handlers.current is None and handlers.train_info is None:
return None
current_operator = resolve_operator(info["operators"])
arrival = format_iso_timestamp(info["arrival"])
departure = format_timestamp(conn['departure'])
operator_infos = operator_metadata(current_operator)
embed = discord.Embed(
title=handlers.train_name,
description=f"Abfahrt von {conn['station']} um {departure}. Ankunft um {arrival}",
color=operator_infos["color"]
)
if len(conn['via']) > 0:
embed.add_field(name="Über", value=format_via_list(conn['via']), inline=False)
if operator_infos.get("unknown"):
logger(f"Metadaten für {current_operator} konten in operators.py nicht gefunden werden")
embed.set_author(name=current_operator)
embed.set_thumbnail(url=operator_infos["logo"])
route_lines = []
for stop in conn['route']:
stop_name = stop["name"]
if stop_name == conn['station']:
route_lines.append(f"• **{stop_name}**")
else:
route_lines.append(f"{stop_name}")
embed.add_field(name="Route", value="\n".join(route_lines))
footer = build_embed_footer(operator_infos.get("slogan"))
embed.set_footer(text=footer["text"], icon_url=footer["icon"])
return embed
def build_announcement_embed(msg):
current_operator = resolve_operator(handlers.train_info["operators"])
operator_infos = operator_metadata(current_operator)
embed = discord.Embed(
title = "Informationen zu Ihrer Fahrt",
description=msg,
color=operator_infos["color"]
)
embed.set_author(name=current_operator)
embed.set_thumbnail(url=operator_infos["logo"])
footer = build_embed_footer(operator_infos.get("slogan"))
embed.set_footer(text=footer["text"], icon_url=footer["icon"])
return embed
def build_error_embed(errormsg) -> discord.Embed:
embed = discord.Embed(
title="Ein Fehler ist aufgetreten!",
description=errormsg,
color=discord.Colour.red()
)
return embed
+154
View File
@@ -0,0 +1,154 @@
import discord
from datetime import datetime, timedelta
import asyncio
import random
from pathlib import Path
from src.config import config
from src.utils import random_connection, get_train_info, get_channel_formatting, logger
from src.embeds import build_announcement_embed, build_info_embed
current = None
_scheduled_task: asyncio.Task | None = None
async def announcer(bot, announcement):
voice_channel = bot.get_channel(config["vc"])
if len(voice_channel.members) > 0:
match announcement:
case "ende":
destination = current['destination']
embed = build_announcement_embed(
f'Sehr geehrte Fahrgäste,\nIn wenigen Minuten erreichen wir {destination}. Dieser Zug endet dort.\n\nWir wünschen Ihnen eine angenehme Weiterreise.\n\nVielen Dank für Ihr Vertrauen und auf Wiedersehen.')
if config["voice_announcements"][0]["enabled"]:
await voice_announcer(bot, destination, voice_channel)
case "umstieg":
embed = build_info_embed()
case _:
embed = None
if embed is None:
logger(f"Unbekanntes Announcement: {announcement}")
else:
await voice_channel.send(embed=embed)
else:
logger(f"Announcement {announcement} wird geskipped, keiner da")
return
async def voice_announcer(bot: discord.Bot, destination, voice_channel):
voice_announcement_config = config["voice_announcements"][0]
voice_stations = voice_announcement_config["stations"]
if destination in voice_stations:
announcement_for = destination
else:
if voice_stations.get("general", "") == "":
return
announcement_for = "general"
if voice_stations.values() == list:
sound_file = random.choice(voice_stations.get(announcement_for))
else:
sound_file = voice_stations.get(announcement_for)
sound_path = f"src/data/announcements/{sound_file}"
if Path(sound_path).is_file() is False:
logger(f"Konnte Datei {sound_path} nicht finden", "error")
return
logger(f"VC wird betreten, spiele {sound_path}")
vc = await voice_channel.connect(timeout=15, reconnect=True)
audio_source = discord.FFmpegPCMAudio(sound_path)
if not vc.is_playing():
def after_playing(error):
if error:
logger(f"Player error: {error}", "error")
bot.loop.create_task(vc.disconnect())
logger("VC wird verlassen")
vc.play(audio_source, after=after_playing)
async def rename_vc(bot: discord.Bot, from_scheduler: bool = False):
global current, train_name, train_info, train_type, _scheduled_task
guild = bot.get_guild(int(config["server"]))
if guild is None:
logger(f"Es konnte kein Server mit der ID {config['server']} gefunden werden! Ist der Bot ein Member?", "fatal")
return False
channel = guild.get_channel(int(config["vc"]))
if not isinstance(channel, discord.VoiceChannel):
logger(f"Es konnte kein VC mit der ID {config['vc']} auf dem Server gefunden werden", "fatal")
return False
if not from_scheduler and _scheduled_task and not _scheduled_task.done():
_scheduled_task.cancel()
attempt = 0
while True:
if attempt == 20:
logger("Zu viele Fehlversuche. Füge einen anderen Bahnhof hinzu.", "fatal")
return "Es konnte kein Zug gefunden werden."
attempt += 1
current = random_connection()
if current == None:
return None
parts = current['train'].split()
train_type = parts[0]
if parts[1].isdigit():
train = current['train']
train_ID = parts[1]
else:
train = parts[1]
train_ID = current['train_number']
station = current['station']
train_info = get_train_info(station=station, train_ID=train_ID, train_type=train_type)
if train_info and train_info.get('operators') and train_info.get('arrival'):
break
logger(f"Versuch {attempt}: Fehler bei {current['train']} von {station}, versuche neue Verbindung...")
train_name = f"{train} nach {current['destination']} von {current['station']}"
logger(f"Vorbereitung auf {train_name} (typ: {train_type})")
arrival = datetime.fromisoformat((train_info["arrival"]))
formatting = get_channel_formatting(train_type)
print("-----------------------------------------")
logger(f"Umstieg: {train_name}")
logger(f"Betreiber: {''.join(train_info['operators'])}")
logger(f"Train-Type: {train_type}")
logger(f"Wenn der Name nicht geändert wird bin ich im Cooldown")
await channel.edit(name=f"{formatting}{train_name}")
await channel.set_status(f"Ankunft um {arrival.strftime('%H:%M')}")
logger(f"Name geändert!")
if config.get("announcements", True):
await announcer(bot, "umstieg")
_scheduled_task = asyncio.create_task(_schedule_next_umstieg(bot, arrival))
return True
async def _schedule_next_umstieg(bot, arrival):
announcement = config.get("announcements", True)
announcement_countdown = random.randrange(180, 300) # letzte station announcement ist meistens 3-5min vor ankunft
wait_seconds = (arrival - datetime.now()).total_seconds()
if wait_seconds > 0:
remaining = str(timedelta(seconds=wait_seconds))
logger(f"Nächster Umstieg in {remaining.split('.')[0]} ({arrival.strftime('%H:%M:%S')} Uhr)")
if announcement and wait_seconds > announcement_countdown:
wait_until_end_announcement = wait_seconds - announcement_countdown
await asyncio.sleep(wait_until_end_announcement)
await announcer(bot, "ende")
await asyncio.sleep(announcement_countdown)
else:
await asyncio.sleep(wait_seconds)
logger("Zug angekommen, wähle neue Verbindung...")
await rename_vc(bot, from_scheduler=True)
-61
View File
@@ -1,61 +0,0 @@
# generated by datamodel-codegen:
# filename: en.yaml
# timestamp: 2026-08-18T20:08:48+00:00
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class LongName:
train_from: str
train_via: str
@dataclass
class Channel:
long_name: LongName
@dataclass
class Footer:
notice: str
@dataclass
class Info:
description: str
via: str
via_and: str
next_stop: str
@dataclass
class EndOfConnection:
message: str
@dataclass
class Announcement:
title: str
end_of_connection: EndOfConnection
@dataclass
class Embeds:
footer: Footer
info: Info
announcement: Announcement
@dataclass
class RichPresence:
arrival: str
@dataclass
class Model:
channel: Channel
embeds: Embeds
rich_presence: RichPresence
-34
View File
@@ -1,34 +0,0 @@
import yaml
import sys
from src.utils import logger
from src.config import config
from src.lang.language_models import Model
class AutoFormatStr(str):
def __call__(self):
frame = sys._getframe(1)
context = {**frame.f_globals, **frame.f_locals}
return self.format(**context)
class LanguageObject:
def __init__(self, dictionary: dict):
for key, value in dictionary.items():
if isinstance(value, dict):
setattr(self, key, LanguageObject(value))
elif isinstance(value, str):
setattr(self, key, AutoFormatStr(value))
else:
setattr(self, key, value)
def load_language(lang_code):
path = f"src/data/locales/{lang_code}.yaml"
try:
with open(path, "r", encoding="utf-8") as file:
data = yaml.safe_load(file)
return LanguageObject(data)
except Exception as e:
logger(f"Failed loading language file: {e}", "FATAL")
lang: Model = load_language(config.discord.lang)
+138 -193
View File
@@ -1,204 +1,149 @@
import requests
import random
import os import os
import importlib import importlib
import random from datetime import datetime
from pathlib import Path
from datetime import datetime, timedelta, timezone
from src.config import config from src.config import config
from zoneinfo import ZoneInfo import src.data.operators as operators_module
import src.data.emojis as emojis
import src.data.operators as operators _operators_mtime = None
from src.data.emojis import emoji_list dbf = config.get("dbf", "https://dbf.finalrewind.org")
_operator_mtime = None def _reload_operators_if_changed():
LOCAL_TZ = ZoneInfo(config.connections.timezone) global _operators_mtime
def logger(msg, log_type="info") -> str: path = operators_module.__file__
current_mtime = os.path.getmtime(path)
if _operators_mtime is None:
_operators_mtime = current_mtime
return
if current_mtime != _operators_mtime:
importlib.reload(operators_module)
_operators_mtime = current_mtime
logger("operators.py wurde automatisch neu geladen (Änderungen erkannt)")
def random_connection():
available_stations = config["stations"].copy()
while True:
if not available_stations:
logger("Keine validen Bahnhöfe. Schlag den richtigen Bahnhofsnamen auf {dbf} nach", "fatal")
station = random.choice(available_stations)
url = f"{dbf}/{station}.json"
blacklist = config.get("blacklist", [])
try:
response = requests.get(url)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(f"Fehler beim aussuchen der Verbindung: {e}", "fatal")
return
if response.status_code == 300:
logger(f"Bahnhof '{station}' konnte nicht gefunden werden ({url})", "error")
available_stations.remove(station)
continue
departures = [
d for d in data.get("departures", [])
if d.get("scheduledDeparture") and d.get("destination") != station
and not d.get("train", "").startswith(tuple(blacklist))
]
if not departures:
continue
if not config['random']:
dep = departures[0]
else:
dep = random.choice(departures)
return {
"train": dep['train'],
"destination": dep['destination'],
"route": dep['route'],
"departure": dep['scheduledDeparture'],
"via": dep['via'],
"station": station,
"train_number": dep['trainNumber']
}
def get_train_info(station, train_ID, train_type):
url = f"{dbf}/z/{train_type}%20{train_ID}/{station}.json"
logger(f"Fetche {url}")
try:
response = requests.get(url)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(f"ReqestException Fehler: {e}")
return None
dep = data.get("departure", [])
if not dep:
logger("Kein departure Feld gefunden", "error")
return None
route_post = dep.get("route_post_diff")
if not route_post:
logger("Kein route_post_diff Feld gefunden", "error")
return None
arrival_iso = route_post[-1].get("sched_arr")
if not arrival_iso:
logger("Keine Ankunftszeit gefunden", "error")
return None
return {
"arrival": arrival_iso,
"operators": dep.get("operators"),
}
def format_via_list(via: list[str]):
if not via:
return ""
if len(via) == 1:
return via[0]
return ", ".join(via[:-1]) + " und " + via[-1]
def resolve_operator(operators):
if not operators:
return None
for op in operators:
if op in operators_module.OPERATOR_ALIASES or op in operators_module.OPERATORS:
return op
return operators[0]
def operator_metadata(operator):
_reload_operators_if_changed()
if not operator:
return operators_module.OPERATORS["fallback"]
if operator in operators_module.OPERATOR_ALIASES:
return operators_module.OPERATOR_ALIASES[operator]
return operators_module.OPERATORS.get(operator, operators_module.OPERATORS["fallback"])
def get_channel_formatting(train_type):
formatting = config.get("formatting", "")
train_emoji = ""
if config.get("emojis", True):
train_emoji = emojis.train_types.get(train_type)
if train_emoji is None:
train_emoji = emojis.emoji_list.get("Fallback", "")
return f"{train_emoji}{formatting}"
def logger(msg, log_type="info"):
status = log_type.upper() status = log_type.upper()
current_time = datetime.now().strftime('%X') current_time = datetime.now().strftime('%X')
print(f"{current_time}: {status}: {msg}") print(f"{current_time}: {status}: {msg}")
if status == "FATAL": if status == "FATAL":
os._exit(1) os._exit(1)
def choose_connection() -> dict | None:
from src.api import transitous
station_id = transitous.get_random_stop_id()
connection = transitous.get_random_connection(station_id)
trip = transitous.get_trip_details(connection)
return trip
def validate_connection(start_time: str, end_time: str, departure_time_iso: str) -> bool:
now = datetime.now(timezone.utc)
start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
if end_dt < now:
logger(f"Connection is from the past: {start_dt}", "error")
return False
max_wait_time = config.connections.max_wait_time
if 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")
return False
departure_time_iso_dt = datetime.fromisoformat(departure_time_iso.replace("Z", "+00:00"))
trip_duration = (end_dt - departure_time_iso_dt).total_seconds()
trip_duration_minutes = str(timedelta(seconds=trip_duration))
min_duration = config.connections.min_duration
min_duration_seconds = min_duration * 60
if trip_duration < min_duration_seconds:
logger(f"Connection is with {trip_duration_minutes} minutes too short (configured to {min_duration} minutes or more), retrying...", "error")
return False
max_duration = config.connections.max_duration
if max_duration:
max_duration_seconds = max_duration * 60
if max_duration_seconds < trip_duration:
logger(f"Connection is with {trip_duration_minutes} too long (configured to {max_duration} minutes at most), retrying...", "error")
return False
return True
def parse_iso(iso_str: str) -> datetime:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
return dt.astimezone(LOCAL_TZ)
def channel_formatting(mode: str) -> str:
formatting = config.discord.formatting
emoji = ""
if config.discord.emojis:
emoji = emoji_list.get(mode)
if emoji is None:
emoji = emoji_list.get("Fallback")
return f"{emoji}{formatting}"
def get_train_name(train_name: str, mode: str) -> str:
if train_name.isdigit():
train = f"{mode.capitalize()} {train_name}"
elif "(" in train_name:
train = train_name.split(" (")[0]
else:
train = train_name
return train
def _reload_operators_if_changed():
global _operator_mtime
path = operators.__file__
current_mtime = os.path.getmtime(path)
if _operator_mtime is None:
_operator_mtime = current_mtime
return
if current_mtime != _operator_mtime:
importlib.reload(operators)
_operator_mtime = current_mtime
def get_operator_metadata(agency: str, route_color: str, mode: str) -> dict:
_reload_operators_if_changed()
op_data = operators.OPERATOR_ALIASES.get(agency) or operators.OPERATORS.get(agency) or operators.OPERATORS["fallback"]
logo = op_data.get("logo", operators.OPERATORS["fallback"]["logo"])
slogans = op_data.get("slogan")
color = op_data.get("color")
if color is None or color == operators.OPERATORS["fallback"]["color"]:
if route_color is not None:
try:
color = int(route_color, 16)
except ValueError:
color = operators.OPERATORS["fallback"]["color"]
else:
color = operators.OPERATORS["fallback"]["color"]
return {
"logo": logo,
"color": color,
"slogans": slogans
}
def get_sound_path(station: str) -> str | None:
announcement_dir = Path("src/data/announcements")
for file in announcement_dir.iterdir():
if file.is_file():
if station.lower() in file.stem.lower():
sound_file = file.resolve()
return sound_file
def get_next_station(stops: dict, train_from: str) -> dict | None:
now = datetime.now(LOCAL_TZ)
for name, info in stops.items():
arrival_dt = info["arrival"]
if arrival_dt >= now:
if name == train_from:
return None
else:
return {
"name": name,
"arrival": arrival_dt
}
return None
def format_via_list(stops: dict, via_and: str) -> str:
if len(stops) > 2:
stations = list(stops.keys())
trip_from = stations[0]
trip_to = stations[-1]
important_stops = sort_stations_by_importance(stops)[:3]
if trip_from in important_stops:
important_stops.remove(trip_from)
if trip_to in important_stops:
important_stops.remove(trip_to)
if len(important_stops) > 1:
via = f"{', '.join(important_stops[:-1])} {via_and} {important_stops[-1]}"
else:
via = important_stops[0]
return via
return None
def sort_stations_by_importance(stops: dict) -> list:
sorted_stations_dict = dict(
sorted(
[(name, info["importance"]) for name, info in stops.items()],
key=lambda x: x[1],
reverse=True)
)
sorted_stations = list(sorted_stations_dict.keys())
return sorted_stations
def format_stop_list(stops: dict, next_stop: str | None) -> list[tuple[str, str]]:
fields = []
field_lines, field_length, part = [], 0, 1
for name, info in stops.items():
if name == next_stop:
stop_arrival = info["arrival"]
line = f"• __{name}__ ({stop_arrival.strftime("%H:%M")})"
else:
stop_arrival = info["arrival"]
line = f"{name} ({stop_arrival.strftime("%H:%M")})"
if field_length + len(line) + 1 > 1024:
route_page_name = "Route" if part == 1 else "Route (Fortsetzung)"
fields.append((route_page_name, "\n".join(field_lines)))
field_lines, field_length, part = [], 0, part + 1
field_lines.append(line)
field_length += len(line) + 1
if field_lines:
route_page_name = "Route" if part == 1 else "Route (Fortsetzung)"
fields.append((route_page_name, "\n".join(field_lines)))
return fields