Compare commits

..
9 Commits
15 changed files with 281 additions and 286 deletions
-174
View File
@@ -1,175 +1 @@
# Gleiswechsel # Gleiswechsel
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.
![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
```sh
git clone https://github.com/kaaninchen/Gleiswechsel.git
# Mit Python
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Mit uv
uv venv venv
source venv/bin/activate
uv pip install -r requirements.txt
```
### Config
`$ cp config.json.example config.json`
```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
}
```
<details>
<summary>weitere Config Erklärungen</summary>
#### 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.
#### 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.
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.
#### 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
#### voice_announcements:
⚠️ Announcements muss aktiviert sein
Der Bot kann 3-5 Min vor Umstieg den VC joinen und eine Audiodatei abspielen. Dafür braucht der Bot ffmpeg.
```sh
$ sudo apt install ffmpeg # Debian/Ubuntu
$ brew install ffmpeg # macOS (brew)
```
##### Voice_announcements config:
```json
"voice_announcements": [
{
"enabled": true,
"stations": {
"general": "general.aac",
"Hannover": "hannover.aac"
}
}
],
```
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:
```json
"stations": {
"general": ""
}
```
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.
```json
"stations": {
"general": ["general_1.aac", "general_2.aac"]
}
```
#### blacklist:
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:
```json
{
"blacklist": [
"ICE",
"NJ",
"ES"
]
}
```
Die Namen der einzelnen Zugtypen kann im Footer von `/info` oder im Terminal log erfahren.
#### src/data
Es kann vorkommen, dass während dem `/info` Befehl das Logo und die Farbe des Bahnuntermehns fehlt.
![Beispiel für fehlende Daten](.github/info_fehlende_daten.png)
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.
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.
Den Status, den sich der Bot alle 5 Minuten random auswählt, kann man in [src/data/status.py](src/data/status.py) anpassen.
</details>
### Running
```sh
# Python
python main.py
# ODER
python3 main.py
# uv
uv run main.py
```
## Bekannte Bugs
#### 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.
```json
{
"blacklist": [
"S "
]
}
```
#### 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.
![Beispiel für Nahreisezug](.github/info_nahreisezug.png)
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.
Die Fallback Metadaten können in [src/data/operators.py](src/data/operators.py) angepasst werden.
+4 -1
View File
@@ -3,12 +3,15 @@
"token": "", "token": "",
"server": , "server": ,
"vc": , "vc": ,
"lang": "de",
"formatting": "┇", "formatting": "┇",
"emojis": true "emojis": true
}, },
"connections": { "connections": {
"stations": [ "stations": [
"" "Berlin Hauptbahnhof",
"Amsterdam",
"Helsinki"
], ],
"blacklist": [], "blacklist": [],
"min_duration": 5, "min_duration": 5,
+2 -5
View File
@@ -21,21 +21,18 @@ if len(sys.argv) > 1:
async def on_ready(): async def on_ready():
global _bot_initialized global _bot_initialized
logger(f"{bot.user} ist online")
if not _bot_initialized: if not _bot_initialized:
logger(f"{bot.user} is online")
_bot_initialized = True _bot_initialized = True
server_id = config.discord.server server_id = config.discord.server
server_vc_id = config.discord.vc server_vc_id = config.discord.vc
channel = validate_channel(bot=bot, server_id=server_id, channel_id=server_vc_id) channel = validate_channel(bot=bot, server_id=server_id, channel_id=server_vc_id)
await rename_vc(bot, voice_channel=channel) await rename_vc(bot, voice_channel=channel)
else:
logger("Reconnected to discord gateway, this wont disturb your current ride")
try: try:
bot.run(config.discord.token) bot.run(config.discord.token)
except: except:
logger("Feher peim parsen des tokens", "fatal") logger("An error occured while parsing the token", "fatal")
''' '''
TODO TODO
+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
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
+29 -11
View File
@@ -5,9 +5,12 @@ from datetime import datetime, timezone
from src.utils import logger, get_train_name, validate_connection, parse_iso from src.utils import logger, get_train_name, validate_connection, parse_iso
from src.config import config from src.config import config
from src.lang.locales import lang
stations = config.connections.stations stations = config.connections.stations
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 = {
@@ -110,6 +113,7 @@ def get_random_stop_id() -> str | None:
if stop_name == assigned_station: if stop_name == assigned_station:
stop_ids.clear() stop_ids.clear()
logger(f"Exact match found! Using {stop_name}")
stop_ids[stop_id] = stop_name stop_ids[stop_id] = stop_name
break break
@@ -118,11 +122,11 @@ 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())
stops_string = ", ".join(stop_ids.values())
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({stops_string}) logger(f"Run `python run main.py stations` to get exact station names")
chosen_stop_id = random.choice(stop_ids_list) chosen_stop_id, chosen_station = random.choice(list(stop_ids.items()))
logger(f"Assigned Station: {chosen_station}")
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:
@@ -200,6 +204,7 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
logger(f"An error occured while trying to get the route details: {e}", "error") logger(f"An error occured while trying to get the route details: {e}", "error")
return None return None
legs = data["legs"][0] legs = data["legs"][0]
end_time = legs["endTime"] end_time = legs["endTime"]
from_station = random_connection["from_station"] from_station = random_connection["from_station"]
@@ -209,14 +214,18 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
start_time = legs["startTime"] start_time = legs["startTime"]
mode = legs["mode"] 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) 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) train_name = get_train_name(display_name, mode)
if train_from == from_station: if train_from == from_station:
long_name = f"{train_name} nach {goes_to} von {train_from}" long_name = long_name_lang.train_from()
else: else:
long_name = f"{train_name} nach {goes_to} über {from_station}" long_name = long_name_lang.train_via()
trip_details = { trip_details = {
"long_name": long_name, "long_name": long_name,
@@ -226,25 +235,34 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
"to": goes_to, "to": goes_to,
"agency": legs["agencyName"], "agency": legs["agencyName"],
"route_color": legs.get("routeColor"), "route_color": legs.get("routeColor"),
"duration": legs["duration"],
"departure": departure_dt.strftime("%H:%M"), "departure": departure_dt.strftime("%H:%M"),
"departure_dt": departure_dt,
"arrival": arrival_dt.strftime("%H:%M"), "arrival": arrival_dt.strftime("%H:%M"),
"arrival_dt": arrival_dt,
"mode": mode, "mode": mode,
"stops": {} "stops": {}
} }
trip_details["stops"][train_from] = departure_dt train_from_importance = legs["from"]["importance"]
trip_details["stops"][train_from] = {
"arrival": departure_dt,
"importance": train_from_importance
}
departure_time_iso = start_time departure_time_iso = start_time
for stop in legs["intermediateStops"]: for stop in legs["intermediateStops"]:
stop_arrival_dt = parse_iso(stop["arrival"]) stop_arrival_dt = parse_iso(stop["arrival"])
trip_details["stops"][stop["name"]] = stop_arrival_dt 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: 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")
trip_details["stops"][goes_to] = arrival_dt
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
+1 -1
View File
@@ -1,5 +1,4 @@
import json import json
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
@@ -8,6 +7,7 @@ class DiscordConfig:
token: str token: str
server: int server: int
vc: int vc: int
lang: str
formatting: str formatting: str
emojis: bool emojis: bool
+16
View File
@@ -0,0 +1,16 @@
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."
+16
View File
@@ -0,0 +1,16 @@
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."
+11 -1
View File
@@ -106,11 +106,21 @@ OPERATORS = {
"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
},
"GVB": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/BSicon_LOGO_GVB.svg/960px-BSicon_LOGO_GVB.svg.png",
"color": 0x2B62AF
},
"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
} }
} }
OPERATOR_ALIASES = { OPERATOR_ALIASES = {
"Arverio BAyern GmbH": OPERATORS["Arverio Bayern"], "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ü"],
+13 -8
View File
@@ -3,9 +3,12 @@ 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
lang_embed = lang.embeds
def build_embed_footer(mode: str, slogans): def build_embed_footer(mode: str, slogans):
footer_notice = f"Data provided by https://transitous.org • mode: {mode}" 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" icon = "https://raw.githubusercontent.com/kaaninchen/Gleiswechsel/refs/heads/main/src/data/assets/transitous-logo.png"
if slogans is not None: if slogans is not None:
@@ -18,17 +21,19 @@ def build_embed_footer(mode: str, slogans):
"icon": icon "icon": icon
} }
def build_info_embed() -> discord.Embed: def build_info_embed() -> discord.Embed:
from src.dc.handlers import trip from src.dc.handlers import trip
station = trip["station"]
agency = trip["agency"] agency = trip["agency"]
metadata = get_operator_metadata(agency, trip["route_color"]) metadata = get_operator_metadata(agency, trip["route_color"], trip["mode"])
departure = format_timestamp_to_dc(trip["departure"]) departure = format_timestamp_to_dc(trip["departure"])
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["long_name"],
description=f"Abfahrt von {trip["station"]} um {departure}. Ankunft um {arrival}", description=lang_embed.info.description(),
color = metadata["color"] color = metadata["color"]
) )
@@ -39,12 +44,12 @@ def build_info_embed() -> discord.Embed:
if next_stop: if next_stop:
next_stop_station = next_stop.get("name") next_stop_station = next_stop.get("name")
via = format_via_list(stops) via = format_via_list(stops, lang_embed.info.via_and())
if via: if via:
embed.add_field(name="Über", value=via, inline=False) embed.add_field(name=lang_embed.info.via(), value=via, inline=False)
if next_stop: if next_stop:
embed.add_field(name="Nächster Halt", value=f"__{next_stop_station}__", inline=False) 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) route_fields = format_stop_list(stops, next_stop_station)
for field_name, field_value in route_fields: for field_name, field_value in route_fields:
@@ -61,10 +66,10 @@ def build_info_embed() -> discord.Embed:
def build_announcement_embed(msg): def build_announcement_embed(msg):
from src.dc.handlers import trip from src.dc.handlers import trip
agency = trip["agency"] agency = trip["agency"]
metadata = get_operator_metadata(agency, trip["route_color"]) metadata = get_operator_metadata(agency, trip["route_color"], trip["mode"])
embed = discord.Embed( embed = discord.Embed(
title = "Informationen zu ihrer Fahrt", title = lang_embed.announcement.title(),
description=msg, description=msg,
color=metadata["color"] color=metadata["color"]
) )
+45 -51
View File
@@ -1,12 +1,14 @@
import discord import discord
import asyncio import asyncio
import random import random
from datetime import datetime, timedelta, date from datetime import datetime, timedelta
from src.utils import logger, channel_formatting, choose_connection, get_sound_path from src.utils import logger, channel_formatting, choose_connection, get_sound_path, LOCAL_TZ, get_next_station
from src.config import config from src.config import config
from src.lang.locales import lang
_scheduled_task: asyncio.Task | None = None _scheduled_task: asyncio.Task | None = None
channel_lang = lang.channel
async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = False): async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = False):
global trip, _scheduled_task global trip, _scheduled_task
@@ -18,7 +20,6 @@ async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = Fals
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
logger(f"Attempt {attempt}: Failed to select route, retrying...", "error")
trip = choose_connection() trip = choose_connection()
if trip is None: if trip is None:
@@ -30,78 +31,49 @@ async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = Fals
mode = trip["mode"] mode = trip["mode"]
print("-----------------") print("-----------------")
logger(f"Umstieg: {long_name}; Ankunft: {arrival} Uhr") logger(f"Transfer: {long_name}; Arrival: {arrival}")
logger(f"Betreiber: {trip["agency"]}, Typ: {mode}") logger(f"Agency: {trip["agency"]}, mode: {mode}")
logger(f"Versuche Namen zu ändern, wenn nichts passiert bin ich im cooldown... (warte bis zu 10min!)") logger(f"Trying to change channel name. Discord put the bot into a cooldown if nothing happens... (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}{long_name}")
await voice_channel.set_status(f"Ankunft um {arrival}") await voice_channel.set_status(channel_lang.status())
logger(f"Name geändert!") logger(f"Updated channel name!")
await announcer("umstieg", voice_channel) await announcer("transfer", voice_channel)
_scheduled_task = asyncio.create_task(_schedule_next_transfer(bot, arrival, voice_channel, trip["to"])) _scheduled_task = asyncio.create_task(_schedule_next_transfer(bot, trip["arrival_dt"], voice_channel, trip["to"]))
async def _schedule_next_transfer(bot: discord.Bot, arrival, voice_channel: discord.VoiceChannel, destination: str):
now = datetime.now()
parsed_time = datetime.strptime(arrival, "%H:%M").time()
arrival_dt = datetime.combine(date.today(), parsed_time)
if arrival_dt < now:
arrival_dt += timedelta(days=1)
wait_seconds = (arrival_dt - now).total_seconds()
announcement_countdown = random.randrange(180, 300)
announcement_countdown = 3
if wait_seconds > 0:
remaining = str(timedelta(seconds=wait_seconds))
logger(f"Nächster Umstieg in {remaining.split('.')[0]} ({arrival} Uhr)")
if wait_seconds > announcement_countdown:
wait_until_end_announcement = wait_seconds - announcement_countdown
await asyncio.sleep(wait_until_end_announcement)
await announcer("ende", voice_channel, destination)
await asyncio.sleep(announcement_countdown)
else:
await asyncio.sleep(wait_seconds)
logger("Zug angekommen, wähle neue Verbindung")
await rename_vc(bot, voice_channel, from_scheduler=True)
async def announcer(announcement: str, voice_channel: discord.VoiceChannel, destination = None): async def announcer(announcement: str, voice_channel: discord.VoiceChannel, destination = None):
from src.dc.embeds import build_info_embed, build_announcement_embed from src.dc.embeds import build_info_embed, build_announcement_embed
announcements_enabled = config.announcements.enabled announcements_enabled = config.announcements.enabled
voice_announcement_enabled = config.announcements.voice[0].enabled voice_announcement_enabled = config.announcements.voice[0].enabled
if announcements_enabled: if announcements_enabled:
if len(voice_channel.members) > 0: if len(voice_channel.members) > 0:
match announcement: match announcement:
case "ende": case "end_of_connection":
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 voice_announcement_enabled: if voice_announcement_enabled:
await voice_announcer(destination, voice_channel) announcement_status = await voice_announcer(destination, voice_channel)
case "umstieg": if announcement_status:
return
embed = build_announcement_embed(lang.embeds.announcement.end_of_connection.message())
case "transfer":
embed = build_info_embed() embed = build_info_embed()
case _: case _:
logger(f"Unbekanntes Announcements: {announcement}") logger(f"Unknown announcement: {announcement}")
embed = None embed = None
if embed: if embed:
await voice_channel.send(embed=embed) await voice_channel.send(embed=embed)
else:
logger(f"Announcement {announcement} wird geskipped, keiner da")
return
async def voice_announcer(destination: str, voice_channel: discord.VoiceChannel): async def voice_announcer(destination: str, voice_channel: discord.VoiceChannel) -> bool:
sound_path = get_sound_path(destination=destination) sound_path = get_sound_path(destination=destination)
if sound_path is None: if sound_path is None:
return return False
logger(f"VC wird betreten, spiele {sound_path}") logger(f"Joining vc, playing {sound_path}")
vc = await voice_channel.connect(timeout=15, reconnect=True) vc = await voice_channel.connect(timeout=15, reconnect=True)
audio_source = discord.FFmpegPCMAudio(sound_path) audio_source = discord.FFmpegPCMAudio(sound_path)
@@ -112,6 +84,28 @@ async def voice_announcer(destination: str, voice_channel: discord.VoiceChannel)
if error: if error:
logger(f"Player error: {error}", "error") logger(f"Player error: {error}", "error")
loop.create_task(vc.disconnect()) loop.create_task(vc.disconnect())
logger("VC wird verlassen") logger("Leaving vc")
vc.play(audio_source, after=after_playing) 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')} Uhr)")
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)
+2 -2
View File
@@ -6,12 +6,12 @@ def validate_channel(bot: discord.bot, server_id: int, channel_id: int):
guild = bot.get_guild(server_id) guild = bot.get_guild(server_id)
if guild is None: if guild is None:
logger(f"Es konnte kein Server mit der ID {server_id} gefunden werden", "fatal") logger(f"Couldn't find server with ID '{server_id}', is the bot invited?", "fatal")
return False return False
channel = guild.get_channel(channel_id) channel = guild.get_channel(channel_id)
if not isinstance(channel, discord.VoiceChannel): if not isinstance(channel, discord.VoiceChannel):
logger(f"Es konnte kein VC mit der id {channel_id} gefunden werden", "fatal") logger(f"Couldn't find vc with '{channel_id}'", "fatal")
return False return False
return channel return channel
+55
View File
@@ -0,0 +1,55 @@
# generated by datamodel-codegen:
# filename: de.yaml
# timestamp: 2026-08-17T18:44:37+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 Model:
channel: Channel
embeds: Embeds
+34
View File
@@ -0,0 +1,34 @@
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)
+45 -25
View File
@@ -27,35 +27,35 @@ def choose_connection() -> dict | None:
return trip return trip
def validate_connection(start_time: str, end_time: str, station_departure: 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"))
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"Verbindung liegt bereits in der Vergangenheit: {start_dt}", "error") logger(f"Connection is from the past: {start_dt}", "error")
return False return False
max_wait_time = config.connections.max_wait_time max_wait_time = config.connections.max_wait_time
start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
if start_dt > now + timedelta(hours=max_wait_time): if start_dt > now + timedelta(hours=max_wait_time):
logger(f"Verbindung liegt zu weit in der Zukunft: {start_dt}", "error") logger(f"Connection is way too far in the future: {start_dt} (max_wait_time: {max_wait_time}h)", "error")
return False return False
station_departure_dt = datetime.fromisoformat(station_departure.replace("Z", "+00:00")) departure_time_iso_dt = datetime.fromisoformat(departure_time_iso.replace("Z", "+00:00"))
trip_duration = (end_dt - station_departure_dt).total_seconds() trip_duration = (end_dt - departure_time_iso_dt).total_seconds()
trip_duration_minutes = str(timedelta(seconds=trip_duration)) trip_duration_minutes = str(timedelta(seconds=trip_duration))
min_duration = config.connections.min_duration min_duration = config.connections.min_duration
min_duration_seconds = min_duration * 60 min_duration_seconds = min_duration * 60
if trip_duration < min_duration_seconds: if trip_duration < min_duration_seconds:
logger(f"Verbindung ist mit {trip_duration_minutes} zu kurz (mindestens {min_duration} Minuten gewollt)", "error") logger(f"Connection is with {trip_duration_minutes} minutes too short (configured to {min_duration} minutes or more)", "error")
return False return False
max_duration = config.connections.max_duration max_duration = config.connections.max_duration
if max_duration: if max_duration:
max_duration_seconds = max_duration * 60 max_duration_seconds = max_duration * 60
if max_duration_seconds < trip_duration: if max_duration_seconds < trip_duration:
logger(f"Verbindung ist mit {trip_duration_minutes} zu lang (höchstens {max_duration} Minuten gewollt)", "error") logger(f"Connection is with {trip_duration_minutes} too long (configured to {max_duration} minutes at most)", "error")
return False return False
return True return True
@@ -66,8 +66,9 @@ def parse_iso(iso_str: str) -> datetime:
def channel_formatting(mode: str) -> str: def channel_formatting(mode: str) -> str:
formatting = config.discord.formatting formatting = config.discord.formatting
emoji = ""
if config.discord.formatting: if config.discord.emojis:
emoji = emoji_list.get(mode) emoji = emoji_list.get(mode)
if emoji is None: if emoji is None:
emoji = emoji_list.get("Fallback") emoji = emoji_list.get("Fallback")
@@ -75,7 +76,7 @@ def channel_formatting(mode: str) -> str:
return f"{emoji}{formatting}" return f"{emoji}{formatting}"
def get_train_name(train_name: str, mode: str) -> str: def get_train_name(train_name: str, mode: str) -> str:
if mode == "BUS" or mode == "TRAM" or train_name.isdigit(): if train_name.isdigit():
train = f"{mode.capitalize()} {train_name}" train = f"{mode.capitalize()} {train_name}"
elif "(" in train_name: elif "(" in train_name:
train = train_name.split(" (")[0] train = train_name.split(" (")[0]
@@ -97,10 +98,8 @@ def _reload_operators_if_changed():
if current_mtime != _operator_mtime: if current_mtime != _operator_mtime:
importlib.reload(operators) importlib.reload(operators)
_operator_mtime = current_mtime _operator_mtime = current_mtime
logger("operators.py wurde automatisch neu geladen (Änderungen erkannt)")
def get_operator_metadata(agency: str, route_color: str, mode: str) -> dict:
def get_operator_metadata(agency: str, route_color: str) -> dict:
_reload_operators_if_changed() _reload_operators_if_changed()
op_data = operators.OPERATOR_ALIASES.get(agency) or operators.OPERATORS.get(agency) or operators.OPERATORS["fallback"] op_data = operators.OPERATOR_ALIASES.get(agency) or operators.OPERATORS.get(agency) or operators.OPERATORS["fallback"]
@@ -115,8 +114,6 @@ def get_operator_metadata(agency: str, route_color: str) -> dict:
color = int(route_color, 16) color = int(route_color, 16)
except ValueError: except ValueError:
color = operators.OPERATORS["fallback"]["color"] color = operators.OPERATORS["fallback"]["color"]
else:
logger(f"Managed to get color from API, add agency to src/data/operators.py if you don't like it")
else: else:
color = operators.OPERATORS["fallback"]["color"] color = operators.OPERATORS["fallback"]["color"]
return { return {
@@ -143,42 +140,65 @@ def get_sound_path(destination) -> str | None:
sound_path = f"src/data/announcements/{sound_file}" sound_path = f"src/data/announcements/{sound_file}"
if Path(sound_path).is_file() is False: if Path(sound_path).is_file() is False:
logger(f"Konnte Datei {sound_path} nicht finden", "error") logger(f"Couldn't find {sound_path}", "error")
return None return None
return sound_path return sound_path
def get_next_station(stops: dict, train_from: str) -> dict | None: def get_next_station(stops: dict, train_from: str) -> dict | None:
now = datetime.now(LOCAL_TZ) now = datetime.now(LOCAL_TZ)
print(train_from) for name, info in stops.items():
for name, arrival_dt in stops.items(): arrival_dt = info["arrival"]
if arrival_dt >= now: if arrival_dt >= now:
if name == train_from: if name == train_from:
return None return None
else: else:
return { return {
"name": name, "name": name,
"arrival": arrival_dt.strftime("%H:%M") "arrival": arrival_dt
} }
return None return None
def format_via_list(stops: dict) -> str: def format_via_list(stops: dict, via_and: str) -> str:
if len(stops) > 2: if len(stops) > 2:
count = min(3, len(stops)) stations = list(stops.keys())
random_stops = random.sample(list(stops), k=count) trip_from = stations[0]
via = f"{', '.join(random_stops[:-1])} und {random_stops[-1]}" 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 via
return None 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]]: def format_stop_list(stops: dict, next_stop: str | None) -> list[tuple[str, str]]:
fields = [] fields = []
field_lines, field_length, part = [], 0, 1 field_lines, field_length, part = [], 0, 1
for name, stop_arrival in stops.items(): for name, info in stops.items():
if name == next_stop: if name == next_stop:
stop_arrival = info["arrival"]
line = f"• __{name}__ ({stop_arrival.strftime("%H:%M")} Uhr)" line = f"• __{name}__ ({stop_arrival.strftime("%H:%M")} Uhr)"
else: else:
stop_arrival = info["arrival"]
line = f"{name} ({stop_arrival.strftime("%H:%M")} Uhr)" line = f"{name} ({stop_arrival.strftime("%H:%M")} Uhr)"
if field_length + len(line) + 1 > 1024: if field_length + len(line) + 1 > 1024: