mirror of
https://github.com/kaaninchen/Gleiswechsel.git
synced 2026-09-17 16:52:47 +00:00
Compare commits
2
Commits
ee51e85786
..
dbf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
199cedb8b1 | ||
|
|
172d6d5cf1 |
@@ -3,4 +3,3 @@ __pycache__/
|
||||
src/data/announcements/*
|
||||
!src/data/announcements/.gitkeep
|
||||
config.json
|
||||
stations.json
|
||||
@@ -1 +1,179 @@
|
||||
# Gleiswechsel
|
||||
|
||||
## THIS BRANCH IS NO LONGER MAINTAINED
|
||||
## This is the code for the legacy Gleiswechsel bot, based on the much simpler dbf API.
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
Der Bot stellt außerdem den `/info` Befehl dar, welcher einem weitere Informationen zur Verbindung zurückgibt
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
+8
-24
@@ -1,36 +1,20 @@
|
||||
{
|
||||
"discord": {
|
||||
"token": "",
|
||||
"stations": ["Hamburg Hbf", "München Hbf", "Köln Hbf", "Amsterdam Centraal"],
|
||||
"dbf": "https://dbf.finalrewind.org",
|
||||
"server": ,
|
||||
"vc": ,
|
||||
"lang": "de",
|
||||
"random": true,
|
||||
"emojis": true,
|
||||
"formatting": "┇",
|
||||
"emojis": true
|
||||
},
|
||||
"connections": {
|
||||
"stations": [
|
||||
"Berlin Hauptbahnhof",
|
||||
"Amsterdam",
|
||||
"Helsinki"
|
||||
],
|
||||
"blacklist": [],
|
||||
"min_duration": 5,
|
||||
"max_duration": null,
|
||||
"max_wait_time": 6,
|
||||
"timezone": "Europe/Berlin"
|
||||
},
|
||||
"announcements": {
|
||||
"enabled": true,
|
||||
"voice": [
|
||||
"announcements": true,
|
||||
"voice_announcements: [
|
||||
{
|
||||
"enabled": false,
|
||||
"stations": {
|
||||
"general": "general.aac",
|
||||
"general": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"http": {
|
||||
"user_agent": "Gleiswechsel-Discord-Bot"
|
||||
}
|
||||
"blacklist": []
|
||||
}
|
||||
@@ -1,42 +1,40 @@
|
||||
import discord
|
||||
import sys
|
||||
|
||||
from src.utils import logger
|
||||
import random
|
||||
from discord.ext import tasks
|
||||
from src.config import config
|
||||
from src.dc.handlers import rename_vc
|
||||
from src.dc.helpers import validate_channel
|
||||
from src.dc.commands import setup_commands
|
||||
from src.api.transitous import check_stations
|
||||
from src.handlers import rename_vc
|
||||
from src.commands import setup_commands
|
||||
from src.embeds import build_error_embed
|
||||
from src.utils import logger
|
||||
from src.data.status import discord_status
|
||||
|
||||
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
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1] == "stations":
|
||||
check_stations()
|
||||
sys.exit(0)
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
global _bot_initialized
|
||||
|
||||
print(f"{bot.user} ist online")
|
||||
if not change_status.is_running():
|
||||
change_status.start()
|
||||
if not _bot_initialized:
|
||||
logger(f"{bot.user} is online")
|
||||
_bot_initialized = True
|
||||
server_id = config.discord.server
|
||||
server_vc_id = config.discord.vc
|
||||
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)
|
||||
else:
|
||||
logger("Discord Reconnect, laufende Fahrt bleibt unangetastet")
|
||||
|
||||
@bot.event
|
||||
async def on_application_command_error(ctx, error):
|
||||
embed = build_error_embed(f"Ein Fehler ist aufgetreten: {error}")
|
||||
await ctx.respond(embed=embed)
|
||||
|
||||
try:
|
||||
bot.run(config.discord.token)
|
||||
bot.run(config['token'])
|
||||
except:
|
||||
logger("An error occured while parsing the token", "fatal")
|
||||
|
||||
'''
|
||||
TODO
|
||||
- discord status
|
||||
- multi language support
|
||||
- README
|
||||
'''
|
||||
logger("Fehler beim parsen des token", "fatal")
|
||||
@@ -13,7 +13,6 @@ numpy==2.5.1
|
||||
pandas==3.0.3
|
||||
propcache==0.5.2
|
||||
py-cord==2.8.0
|
||||
pyyaml==6.0.3
|
||||
pycparser==3.0
|
||||
pynacl==1.6.2
|
||||
python-dateutil==2.9.0.post0
|
||||
|
||||
@@ -1,271 +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
|
||||
|
||||
|
||||
stations = config.connections.stations
|
||||
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():
|
||||
all_stations_output = {}
|
||||
minimal_overview = {
|
||||
"stations": {}
|
||||
}
|
||||
|
||||
for station in stations:
|
||||
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,
|
||||
"modes": modes,
|
||||
"id": station_id,
|
||||
}
|
||||
|
||||
if stop_name:
|
||||
stops_dict[stop_name] = stop_details
|
||||
aliases_list.append(stop_name)
|
||||
|
||||
if stop_name == station:
|
||||
if not exact_match:
|
||||
logger(f"Exact match found! {station} is an assigned station! Bot would use that station directly")
|
||||
exact_match = True
|
||||
|
||||
if not stops_dict:
|
||||
logger("Failed to grab ID from 'search_name'", "error")
|
||||
continue
|
||||
|
||||
all_stations_output[station] = {
|
||||
"associated": stops_dict
|
||||
}
|
||||
|
||||
minimal_overview["stations"][station] = 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:
|
||||
assigned_station = random.choice(stations)
|
||||
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()
|
||||
logger(f"Exact match found! Using {stop_name}")
|
||||
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()))
|
||||
logger(f"Assigned Station: {chosen_station}")
|
||||
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 connection", "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)
|
||||
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")
|
||||
|
||||
valid = validate_connection(start_time, end_time, departure_time_iso)
|
||||
if not valid:
|
||||
return None
|
||||
|
||||
return trip_details
|
||||
|
||||
@@ -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
-55
@@ -1,58 +1,4 @@
|
||||
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]
|
||||
blacklist: list[str]
|
||||
min_duration: int
|
||||
max_wait_time: int
|
||||
timezone: str
|
||||
max_duration: Optional[int] = None
|
||||
|
||||
@dataclass
|
||||
class VoiceAnnouncementConfig:
|
||||
enabled: bool
|
||||
stations: dict[str, str]
|
||||
|
||||
@dataclass
|
||||
class AnnouncementConfig:
|
||||
enabled: bool
|
||||
voice: list[VoiceAnnouncementConfig]
|
||||
|
||||
@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:
|
||||
raw = json.load(file)
|
||||
|
||||
return Config(
|
||||
discord=DiscordConfig(**raw["discord"]),
|
||||
connections=ConnectionsConfig(**raw["connections"]),
|
||||
announcements=AnnouncementConfig(
|
||||
enabled=raw["announcements"]["enabled"],
|
||||
voice=[VoiceAnnouncementConfig(**v) for v in raw["announcements"]["voice"]],
|
||||
),
|
||||
http=HttpConfig(**raw["http"]),
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
config = json.load(file)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,2 +0,0 @@
|
||||
SPDX-FileCopyrightText: 2024 Mathis Brüchert <[email protected]>
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
+16
-9
@@ -1,11 +1,18 @@
|
||||
emoji_list = {
|
||||
"Fallback": "💺",
|
||||
"BUS": "🚎",
|
||||
"COACH": "🚎",
|
||||
"TRAM": "🚈",
|
||||
"REGIONAL_RAIL": "🚊",
|
||||
"HIGHSPEED_RAIL": "🚅",
|
||||
"LONG_DISTANCE": "🚅",
|
||||
"METRO": "🚇",
|
||||
"SUBWAY": "🚇"
|
||||
"Fallback": "🚉",
|
||||
"Nahverkehr": "🚃",
|
||||
"Höchstgeschwindigkeit": "🚄",
|
||||
"Bus": "🚎"
|
||||
}
|
||||
|
||||
train_types = { # Unvollständig!
|
||||
"ICE": emoji_list["Höchstgeschwindigkeit"],
|
||||
"TGV": emoji_list["Höchstgeschwindigkeit"],
|
||||
|
||||
"RE": emoji_list["Nahverkehr"],
|
||||
"RB": emoji_list["Nahverkehr"],
|
||||
"ARV": emoji_list["Nahverkehr"],
|
||||
"BRB": emoji_list["Nahverkehr"],
|
||||
|
||||
"Bus": emoji_list["Bus"]
|
||||
}
|
||||
@@ -1,16 +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."
|
||||
@@ -1,16 +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."
|
||||
+2
-27
@@ -38,7 +38,7 @@ OPERATORS = {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/ODEG-Logo_Neu.svg/960px-ODEG-Logo_Neu.svg.png",
|
||||
"color": 0x00745C
|
||||
},
|
||||
"NS": {
|
||||
"Nederlandse Spoorwegen": {
|
||||
"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"]
|
||||
@@ -98,29 +98,10 @@ OPERATORS = {
|
||||
"enno": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Enno_logo.svg/960px-Enno_logo.svg.png",
|
||||
"color": 0x88216F
|
||||
},
|
||||
"DSB": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/DSB_company_logo.svg/960px-DSB_company_logo.svg.png",
|
||||
"color": 0xB22B32
|
||||
},
|
||||
"Vr": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/Vr_Logo.png/330px-Vr_Logo.png",
|
||||
"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 = {
|
||||
"Arverio Bayern GmbH": OPERATORS["Arverio Bayern"],
|
||||
"DB Regio AG Baden-Württemberg": OPERATORS["db_bawü"],
|
||||
"DB Regio Stuttgart GmbH": OPERATORS["db_bawü"],
|
||||
"Arverio Baden-Württemberg": OPERATORS["db_bawü"],
|
||||
@@ -128,16 +109,10 @@ OPERATOR_ALIASES = {
|
||||
"DB Regio AG Bayern": OPERATORS["db_bayern"],
|
||||
"DB Fernverkehr AG": 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 Nordost": OPERATORS["db_allgemein"],
|
||||
"DB Regio AG Nord": OPERATORS["db_allgemein"],
|
||||
"DB Regio AG Mitte": OPERATORS["db_allgemein"],
|
||||
"NS International": OPERATORS["NS"],
|
||||
"SBB GmbH": OPERATORS["SBB"],
|
||||
"Schweizerische Bundesbahnen SBB": OPERATORS["SBB"],
|
||||
"Dänische Staatsbahnen": OPERATORS["DSB"],
|
||||
"VR": OPERATORS["Vr"]
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -1,111 +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
|
||||
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 channel name. Discord put the bot into a cooldown if nothing happens... (automatically resolves after up to 10min)")
|
||||
|
||||
formatting = channel_formatting(mode)
|
||||
await voice_channel.edit(name=f"{formatting}{long_name}")
|
||||
await voice_channel.set_status(channel_lang.status())
|
||||
|
||||
logger(f"Updated channel name!")
|
||||
|
||||
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.enabled
|
||||
voice_announcement_enabled = config.announcements.voice[0].enabled
|
||||
|
||||
if announcements_enabled:
|
||||
if len(voice_channel.members) > 0:
|
||||
match announcement:
|
||||
case "end_of_connection":
|
||||
if voice_announcement_enabled:
|
||||
announcement_status = await voice_announcer(destination, voice_channel)
|
||||
if announcement_status:
|
||||
return
|
||||
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(destination: str, voice_channel: discord.VoiceChannel) -> bool:
|
||||
sound_path = get_sound_path(destination=destination)
|
||||
if sound_path is None:
|
||||
return False
|
||||
|
||||
logger(f"Joining vc, playing {sound_path}")
|
||||
vc = await voice_channel.connect(timeout=15, reconnect=True)
|
||||
audio_source = discord.FFmpegPCMAudio(sound_path)
|
||||
|
||||
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')} 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)
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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
|
||||
@@ -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
-205
@@ -1,216 +1,149 @@
|
||||
import requests
|
||||
import random
|
||||
import os
|
||||
import importlib
|
||||
import random
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime
|
||||
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
|
||||
from src.data.emojis import emoji_list
|
||||
_operators_mtime = None
|
||||
dbf = config.get("dbf", "https://dbf.finalrewind.org")
|
||||
|
||||
_operator_mtime = None
|
||||
LOCAL_TZ = ZoneInfo(config.connections.timezone)
|
||||
def _reload_operators_if_changed():
|
||||
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()
|
||||
current_time = datetime.now().strftime('%X')
|
||||
print(f"{current_time}: {status}: {msg}")
|
||||
if status == "FATAL":
|
||||
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 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)", "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)", "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)", "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(destination) -> str | None:
|
||||
voice_stations = config.announcements.voice[0].stations
|
||||
|
||||
if destination in voice_stations:
|
||||
announcement_for = destination
|
||||
else:
|
||||
general_sound_enabled = voice_stations.get("general", "")
|
||||
if not general_sound_enabled:
|
||||
return None
|
||||
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"Couldn't find {sound_path}", "error")
|
||||
return None
|
||||
|
||||
return sound_path
|
||||
|
||||
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")} Uhr)"
|
||||
else:
|
||||
stop_arrival = info["arrival"]
|
||||
line = f"• {name} ({stop_arrival.strftime("%H:%M")} Uhr)"
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user