Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f65aa3cfa0 | ||
|
|
e9708d435e | ||
|
|
d50cf0277b | ||
|
|
dde0c443de | ||
|
|
93b9712394 | ||
|
|
21a279839d | ||
|
|
1c006731a4 | ||
|
|
db43ee4279 | ||
|
|
dc2f9dade0 | ||
|
|
10b6ee560e | ||
|
|
53ded15209 | ||
|
|
c24749383b | ||
|
|
5609a3c425 | ||
|
|
8dfa092229 | ||
|
|
62d5569bcc | ||
|
|
f716b770dc | ||
|
|
9e7c645724 | ||
|
|
8c4e78c493 | ||
|
|
7e47874f78 | ||
|
|
1069879680 | ||
|
|
a4ae134ca0 | ||
|
|
054ab7fc42 | ||
|
|
999fe141b9 | ||
|
|
595e7160df | ||
|
|
ead53f2f75 | ||
|
|
ed7ae614cc | ||
|
|
714841df22 | ||
|
|
cd2d968545 | ||
|
|
2e62355db9 | ||
|
|
e6013a437f | ||
|
|
d46a04af0c | ||
|
|
6006a561b2 | ||
|
|
881d2e865b | ||
|
|
66ec499c27 | ||
|
|
7a3902b7d3 | ||
|
|
bd506ff9da | ||
|
|
59b2ccd64a | ||
|
|
768da0ee4d | ||
|
|
637a1f19a0 | ||
|
|
3dcf1e8626 | ||
|
|
729f350924 | ||
|
|
a6342ca039 | ||
|
|
260ab043f4 | ||
|
|
9f566a5bf3 | ||
|
|
000492f320 | ||
|
|
4a73ec6ef2 | ||
|
|
5886c747fe | ||
|
|
b608656bc7 | ||
|
|
ee51e85786 | ||
|
|
a6e15f5cd6 | ||
|
|
bbc2be89e9 | ||
|
|
d4971ab95b | ||
|
|
9cd1f54258 | ||
|
|
60d4936dda | ||
|
|
f990a55da9 | ||
|
|
842c944faa | ||
|
|
21146fff86 | ||
|
|
59af14e6ff |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,6 +1,7 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
src/data/announcements/*
|
||||
src/data/assets/current_map.png
|
||||
!src/data/announcements/.gitkeep
|
||||
config.json
|
||||
stations.json
|
||||
@@ -1,175 +1,219 @@
|
||||
# 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.
|
||||
Gleiswechsel [ˈɡlaɪsvɛksəl] (german for "platform change") is a selfhostable discord bot which regularly changes the name of a voice chat to a real public transport connection. The channel will keep the name until the connection arrived at its last stop, after that it will automatically select a new one.
|
||||
|
||||

|
||||

|
||||
|
||||
Der Bot stellt außerdem den `/info` Befehl dar, welcher einem weitere Informationen zur Verbindung zurückgibt
|
||||
## Features
|
||||
- [International support](https://transitous.org/sources/) for countries as well as cities
|
||||
- Support for various types of transportations, including but not limited to Subways, Busses, Funiculars, Trams and Trains
|
||||
- Multi language support (+ option to easily add more languages)
|
||||
- Announcements, including the option to join the voice chat and play an audio file while arriving at a specific station
|
||||
- Highly customizable
|
||||
- pretty `/info` embed with some details about your current ride
|
||||
|
||||

|
||||

|
||||
|
||||
## Setup
|
||||
```sh
|
||||
git clone https://github.com/kaaninchen/Gleiswechsel.git
|
||||
Follow [this guide](https://guide.pycord.dev/getting-started/creating-your-first-bot) to create your discord bot up until you have a token.
|
||||
|
||||
# Mit Python
|
||||
Make sure that you have [Python](https://www.python.org/) installed!
|
||||
|
||||
Download the repo (either with git or by clicking the big "Code" button at the start on this page, downloading the zip and extracting it) and run:
|
||||
|
||||
```sh
|
||||
# Create a virtual environment so that the bot won't cluster your system with dependencies
|
||||
python -m venv venv
|
||||
|
||||
# Activate your venv, you should have gotten instructions after running the first command, e.g.:
|
||||
source venv/bin/activate
|
||||
|
||||
# Install the required dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Mit uv
|
||||
uv venv venv
|
||||
source venv/bin/activate
|
||||
uv pip install -r requirements.txt
|
||||
|
||||
```
|
||||
|
||||
### Config
|
||||
Next, you should configure the bot to your liking
|
||||
|
||||
`$ 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
|
||||
}
|
||||
## Config
|
||||
Even if it seems a bit tedious, I highly recommend going through the entire config and checking if there's something that you would like to customize.
|
||||
|
||||
I added an [example config](config.json.example). Rename `config.json.example` to `config.json` and fill it out.
|
||||
|
||||
### Config explanations
|
||||
|
||||
#### discord
|
||||
|
||||
- `"token"` is for the discord bot token. You should have copied it earlier
|
||||
|
||||
- `"server"` and `"vc"` are the ID's for the server and the voice chat that the bot should use. Follow [this guide](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID) to get them.
|
||||
|
||||
- `"lang"` is for the language that the bot should use within discord. The relevant language files are located in [src/data/locales/](src/data/locales). Current available languages are english ("en") and german ("de"). Feel free to add your language if you miss it!
|
||||
|
||||
- `"formatting"` and `"emojis"` are for the channel name. Set `emojis` to `False` and `"formatting"` to `null` if you just want the connection as the channel's name without any decor.
|
||||
|
||||
#### connections
|
||||
|
||||
##### stations
|
||||
You can add your desired stations that the bot should search connections from there. The bot will choose one of the stations at random.
|
||||
|
||||
The bot will also search for similar named stations if it couldn't find an exact match to your input. This is really handy if you'd just like to ride around in a city with various stations. For example, if you just input "Amsterdam" as a station, the bot would randomly choose one of these stations:
|
||||
|
||||
```json
|
||||
"Amsterdam": [
|
||||
"Amsterdam Zuid",
|
||||
"Amsterdam RAI",
|
||||
"Amsterdam Amstel",
|
||||
"Amsterdam, Gein",
|
||||
"Amsterdam Centraal",
|
||||
"Amsterdam, Noord",
|
||||
"Amsterdam, Rokin",
|
||||
"Amsterdam Lelylaan",
|
||||
"Amsterdam Sloterdijk"
|
||||
]
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>weitere Config Erklärungen</summary>
|
||||
The bot will also warn you if it couldn't find a station with the exact name as your input:
|
||||
|
||||
#### 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.
|
||||
```
|
||||
22:44:46: INFO: No station associated as 'Amsterdam', choosing random from similar named stations
|
||||
```
|
||||
|
||||
#### 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.
|
||||
but you can safely ignore this warning if you're fine with it.
|
||||
|
||||
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.
|
||||
If not, I've created a tool which would help you to get the exact station name. You can use the tool by running
|
||||
|
||||
```sh
|
||||
$ sudo apt install ffmpeg # Debian/Ubuntu
|
||||
|
||||
$ brew install ffmpeg # macOS (brew)
|
||||
$ python main.py stations
|
||||
```
|
||||
|
||||
##### Voice_announcements config:
|
||||
|
||||
Example output:
|
||||
```json
|
||||
"voice_announcements": [
|
||||
{
|
||||
"enabled": true,
|
||||
"stations": {
|
||||
"general": "general.aac",
|
||||
"Hannover": "hannover.aac"
|
||||
}
|
||||
}
|
||||
"Amsterdam": [
|
||||
"Amsterdam Zuid",
|
||||
"Amsterdam RAI",
|
||||
"Amsterdam Amstel",
|
||||
"Amsterdam, Gein",
|
||||
"Amsterdam Centraal",
|
||||
"Amsterdam, Noord",
|
||||
"Amsterdam, Rokin",
|
||||
"Amsterdam Lelylaan",
|
||||
"Amsterdam Sloterdijk"
|
||||
],
|
||||
```
|
||||
|
||||
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"
|
||||
"Berlin": [
|
||||
"Berlin Hbf",
|
||||
"Berlin ZOB",
|
||||
"Berlin Ostbf",
|
||||
"Berlin-Spandau",
|
||||
"Berlin Südkreuz",
|
||||
"U Rudow (Berlin)",
|
||||
"S Buch (Berlin)",
|
||||
"U Hönow (Berlin)"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Die Namen der einzelnen Zugtypen kann im Footer von `/info` oder im Terminal log erfahren.
|
||||
The tool will also ask if it should save a .json file with more informations for every similar station. If you're unsure about what which station is, then it can be really helpful! It would give you data like which types of transports arrive at every similar station, in which country they are and also their coordinates.
|
||||
|
||||
#### src/data
|
||||
Es kann vorkommen, dass während dem `/info` Befehl das Logo und die Farbe des Bahnuntermehns fehlt.
|
||||
If you choose to generate the json, then you'll find the file as `stations.json` in the same directory as `main.py`
|
||||
|
||||

|
||||
##### IDs
|
||||
For every station inside of the `stations.json` you'll find an ID. You can add that ID to `"IDs"` to really specify that you would like to use THAT station, and not a different one.
|
||||
|
||||
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.
|
||||
This is especially useful if you want to add a station whose name isn't unique and also used by other stations. The bot would falsely use the first station with the same name and consider it an exact match, even if you wanted a different one. This won't happen with the ID, as every ID is uniquely assigned to only one station.
|
||||
|
||||
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.
|
||||
##### priority
|
||||
You can define priorities of transport modes while selecting a connection. Leave empty to disable
|
||||
|
||||
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.
|
||||
The list should be descending, with "1" as the most important transport mode. For example, let's say, the bot should always choose Trains and other longer distance public transport modes. If it can't find any, then it should resort to Metros/Subways/Suburbans. If these are also missing at that station, then it should just use Busses/Trams:
|
||||
|
||||
```json
|
||||
{
|
||||
"blacklist": [
|
||||
"S "
|
||||
]
|
||||
"priority": {
|
||||
"HIGHSPEED_RAIL": 1,
|
||||
"LONG_DISTANCE": 1,
|
||||
"NIGHT_RAIL": 1,
|
||||
"REGIONAL_RAIL": 1,
|
||||
"COACH": 1,
|
||||
"FERRY": 1,
|
||||
"METRO": 2,
|
||||
"SUBURBAN": 2,
|
||||
"SUBWAY": 2,
|
||||
"BUS": 3,
|
||||
"TRAM": 3
|
||||
}
|
||||
```
|
||||
A transport mode that isn't in the priority list would immediately get assigned the value of "99", making it near impossible to get that connection. Except when there are no other connections available, of course.
|
||||
|
||||
You can find the MODE names either in the console log or the stations.json from [helper tool](#stations)
|
||||
|
||||
##### blacklist
|
||||
You can blacklist specific types of transport, the bot would then skip them while selecting a connection. You can get the type in your console (mode)
|
||||
```sh
|
||||
22:44:47: INFO: Agency: GVB, mode: TRAM
|
||||
```
|
||||
|
||||
#### 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.
|
||||
I highly recommend keeping `"OTHER"` blacklisted, if the API doesn't know what that is then we probably shouldn't use it. Also, it would probably a good idea to keep `"RIDE_SHARING"` blacklisted as they have weird timetables
|
||||
|
||||

|
||||
##### min_duration, max_duration, max_wait_time, timezone
|
||||
|
||||
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.
|
||||
- `min_duration` is the minimal duration of the connection in minutes. It is HIGHLY recommended to set it to atleast 10, as discord only allows to change the name of a voice chat twice every 10 minutes.
|
||||
|
||||
Die Fallback Metadaten können in [src/data/operators.py](src/data/operators.py) angepasst werden.
|
||||
- `max_duration` is the maximun duration of the connection in minutes. Set to `null` to disable
|
||||
|
||||
- `max_wait_time` is the maximun wait time for a connection in hours. Don't set that one too low, as the bot may have some issues finding a suitable connection at night, or at stations that aren't frequently used. Set to `null` to disable
|
||||
|
||||
- `timezone` is timezone in the IANA timezone format. You can [look it up here](https://www.addevent.com/c/documentation/tools/time-zone-lookup)
|
||||
|
||||
##### announcements
|
||||
|
||||
##### `text_announcements:`
|
||||
The bot can send a text announcement in the voice chat at the start/end of a trip.
|
||||
At the end of a trip, it would send this embed:
|
||||
|
||||

|
||||
|
||||
It will also send the `/info` embed at the start of a new connection with informations about your new trip.
|
||||
|
||||
To reduce spam, the bot will only send announcements if someone is in the voice chat
|
||||
|
||||
##### `voice_announcements:`
|
||||
The bot can join the voice chat, play an audio file, and disconnect from the voice chat, at various points of your trip. You have to have [FFmpeg](https://www.ffmpeg.org/) installed for this to work.
|
||||
|
||||
Place the audio file of your desired station in [src/data/announcements](src/data/announcements/) with the EXACT name of the station. The bot will automatically check if an audio file with the stations name exists, and if it does, play it.
|
||||
|
||||
You don't have to restart the bot after placing new audio files in the folder.
|
||||
|
||||
#### `map:`
|
||||
The bot can generate a map of your route in the `/info` embed. Set `map` to `true` to enable.
|
||||
|
||||
#### http
|
||||
|
||||
- `"user_agent"`: The user agent of the bot for the API. If you don't know what that is, then you shouldn't have to change that. Modify the contact URL (in my case, the github repo) if you change a lot of the code, so that transitous can contact you in case of any issues
|
||||
|
||||
## Running
|
||||
After you've set everything up, you're ready to start the bot!
|
||||
|
||||
```sh
|
||||
$ python main.py
|
||||
```
|
||||
|
||||
## src/data
|
||||
[src/data](src/data/) contains a few files/directories which may be interesting to customize.
|
||||
|
||||
### [emojis.py](src/data/emojis.py)
|
||||
if `emojis` is enabled in the config, the bot will use that file to determine the right emoji for the voice chats name according to the current mode. You can add missing modes there or customize the emojis
|
||||
|
||||
### [operators.py](src/data/operators.py)
|
||||
Metadata for the `/info` embed. You can assign an agency a logo, color and (optionally) some slogans. The agency name is visible in the console log and the `/info` embed.
|
||||
|
||||
If multiple agencys should use the same metadata, take a look at OPERATOR_ALIASES.
|
||||
|
||||
You don't have to restart the bot after editing this file.
|
||||
|
||||
### [locales/](src/data/locales/)
|
||||
Covered in [locales](#locales)
|
||||
|
||||
### [announcements/](src/data/announcements/)
|
||||
Covered in [voice announcements](#voice)
|
||||
|
||||
### [assets](src/data/assets/)
|
||||
Assets (such as images) which the bot uses. You shouldn't have to change anything there
|
||||
@@ -1,33 +1,35 @@
|
||||
{
|
||||
"discord": {
|
||||
"token": "",
|
||||
"server": ,
|
||||
"vc": ,
|
||||
"server": "",
|
||||
"vc": "",
|
||||
"lang": "de",
|
||||
"formatting": "┇",
|
||||
"emojis": true
|
||||
},
|
||||
"connections": {
|
||||
"stations": [
|
||||
""
|
||||
"Berlin Hauptbahnhof",
|
||||
"Amsterdam",
|
||||
"Helsinki"
|
||||
],
|
||||
"blacklist": [],
|
||||
"min_duration": 5,
|
||||
"IDs": [],
|
||||
"priority": {},
|
||||
"blacklist": [
|
||||
"OTHER",
|
||||
"RIDE_SHARING"
|
||||
],
|
||||
"min_duration": 10,
|
||||
"max_duration": null,
|
||||
"max_wait_time": 6,
|
||||
"max_wait_time": null,
|
||||
"timezone": "Europe/Berlin"
|
||||
},
|
||||
"announcements": {
|
||||
"enabled": true,
|
||||
"voice": [
|
||||
{
|
||||
"enabled": false,
|
||||
"stations": {
|
||||
"general": "general.aac",
|
||||
}
|
||||
}
|
||||
]
|
||||
"text_announcements": true,
|
||||
"voice_announcements": false,
|
||||
"map": true
|
||||
},
|
||||
"http": {
|
||||
"user_agent": "Gleiswechsel-Discord-Bot"
|
||||
"user_agent": "Gleiswechsel-Discord-Bot/1.0 (https://github.com/kaaninchen/Gleiswechsel)"
|
||||
}
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
{
|
||||
"duration": 1440,
|
||||
"startTime": "2026-08-16T21:25:00Z",
|
||||
"endTime": "2026-08-16T21:49:00Z",
|
||||
"transfers": 0,
|
||||
"id": "CogBCgI1NBIiMjAyNjA4MTZfMjM6MjVfbmwtT3Blbk9WXzM3Njc3ODE5OBoRbmwtT3Blbk9WXzM5ODA3NDch/ilVouwlSkAp147iHHX0E0AyEW5sLU9wZW5PVl8zOTc5OTIwOZRIopdRMEpAQcGxZ89lmhNASKzViNQGUMzgiNQGWgZTVUJXQVlgAQ==",
|
||||
"legs": [
|
||||
{
|
||||
"mode": "SUBWAY",
|
||||
"from": {
|
||||
"name": "Amsterdam, Gein",
|
||||
"stopId": "nl-OpenOV_3980747",
|
||||
"parentId": "nl-OpenOV_NL:S:30009530",
|
||||
"importance": 0.018040798604488373,
|
||||
"lat": 52.296284,
|
||||
"lon": 4.988728000000001,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"departure": "2026-08-16T21:25:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:25:00Z",
|
||||
"scheduledTrack": "1",
|
||||
"track": "1",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NOT_ALLOWED",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Stap op Bijlmer ArenA over op NS-treinen naar Zuid of op Spaklerweg voor Overamstel. Details: gvb.nl --|Change at Bijlmer ArenA for NS train",
|
||||
"descriptionText": "Stap op Bijlmer ArenA over op NS-treinen naar Zuid of op Spaklerweg voor Overamstel. Details: gvb.nl --|Change at Bijlmer ArenA for NS trains to Zuid or at Spaklerweg for Overamstel. Details: gvb.nl"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"SUBWAY",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"name": "Amsterdam, Centraal Station",
|
||||
"stopId": "nl-OpenOV_3979920",
|
||||
"parentId": "nl-OpenOV_NL:S:30009500",
|
||||
"importance": 0.17953550815582275,
|
||||
"lat": 52.377489999999995,
|
||||
"lon": 4.900779000000001,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:49:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:49:00Z",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NOT_ALLOWED",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Neem M52 naar Zuid voor RAI en de richting Isolatorweg. Details: gvb.nl/zomermetro|-- Take M52 to Zuid for RAI and the direction Isolatorweg",
|
||||
"descriptionText": "Neem M52 naar Zuid voor RAI en de richting Isolatorweg. Details: gvb.nl/zomermetro|-- Take M52 to Zuid for RAI and the direction Isolatorweg. Details: gvb.nl/summermetro"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"HIGHSPEED_RAIL",
|
||||
"LONG_DISTANCE",
|
||||
"NIGHT_RAIL",
|
||||
"REGIONAL_RAIL",
|
||||
"SUBWAY",
|
||||
"TRAM",
|
||||
"BUS",
|
||||
"FERRY"
|
||||
]
|
||||
},
|
||||
"duration": 1440,
|
||||
"startTime": "2026-08-16T21:25:00Z",
|
||||
"endTime": "2026-08-16T21:49:00Z",
|
||||
"scheduledStartTime": "2026-08-16T21:25:00Z",
|
||||
"scheduledEndTime": "2026-08-16T21:49:00Z",
|
||||
"realTime": true,
|
||||
"scheduled": true,
|
||||
"interlineWithPreviousLeg": false,
|
||||
"headsign": "Centraal Station",
|
||||
"tripFrom": {
|
||||
"name": "Amsterdam, Gein",
|
||||
"stopId": "nl-OpenOV_3980747",
|
||||
"parentId": "nl-OpenOV_NL:S:30009530",
|
||||
"importance": 0.018040798604488373,
|
||||
"lat": 52.296284,
|
||||
"lon": 4.988728000000001,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"departure": "2026-08-16T21:25:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:25:00Z",
|
||||
"scheduledTrack": "1",
|
||||
"track": "1",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NOT_ALLOWED",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Stap op Bijlmer ArenA over op NS-treinen naar Zuid of op Spaklerweg voor Overamstel. Details: gvb.nl --|Change at Bijlmer ArenA for NS train",
|
||||
"descriptionText": "Stap op Bijlmer ArenA over op NS-treinen naar Zuid of op Spaklerweg voor Overamstel. Details: gvb.nl --|Change at Bijlmer ArenA for NS trains to Zuid or at Spaklerweg for Overamstel. Details: gvb.nl"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"SUBWAY",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
"tripTo": {
|
||||
"name": "Amsterdam, Centraal Station",
|
||||
"stopId": "nl-OpenOV_3979920",
|
||||
"parentId": "nl-OpenOV_NL:S:30009500",
|
||||
"importance": 0.17953550815582275,
|
||||
"lat": 52.377489999999995,
|
||||
"lon": 4.900779000000001,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:49:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:49:00Z",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NOT_ALLOWED",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Neem M52 naar Zuid voor RAI en de richting Isolatorweg. Details: gvb.nl/zomermetro|-- Take M52 to Zuid for RAI and the direction Isolatorweg",
|
||||
"descriptionText": "Neem M52 naar Zuid voor RAI en de richting Isolatorweg. Details: gvb.nl/zomermetro|-- Take M52 to Zuid for RAI and the direction Isolatorweg. Details: gvb.nl/summermetro"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"HIGHSPEED_RAIL",
|
||||
"LONG_DISTANCE",
|
||||
"NIGHT_RAIL",
|
||||
"REGIONAL_RAIL",
|
||||
"SUBWAY",
|
||||
"TRAM",
|
||||
"BUS",
|
||||
"FERRY"
|
||||
]
|
||||
},
|
||||
"routeId": "nl-OpenOV_152364",
|
||||
"routeUrl": "",
|
||||
"directionId": "0",
|
||||
"routeColor": "fff200",
|
||||
"routeTextColor": "000000",
|
||||
"routeType": 1,
|
||||
"agencyName": "GVB",
|
||||
"agencyUrl": "http://www.gvb.nl/klantenservice",
|
||||
"agencyFareUrl": "",
|
||||
"agencyId": "GVB",
|
||||
"tripId": "20260816_23:25_nl-OpenOV_376778198",
|
||||
"routeShortName": "54",
|
||||
"routeLongName": "Lijn 54",
|
||||
"tripShortName": "",
|
||||
"displayName": "54",
|
||||
"cancelled": false,
|
||||
"source": "nl_OpenOV.gtfs.zip/stop_times.txt:1582071:1582085",
|
||||
"intermediateStops": [
|
||||
{
|
||||
"name": "Amsterdam, Reigersbos",
|
||||
"stopId": "nl-OpenOV_3979741",
|
||||
"parentId": "nl-OpenOV_NL:S:30009528",
|
||||
"importance": 0.018029676750302315,
|
||||
"lat": 52.29557400000001,
|
||||
"lon": 4.973636000000001,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:26:00Z",
|
||||
"departure": "2026-08-16T21:27:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:26:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:27:00Z",
|
||||
"scheduledTrack": "2",
|
||||
"track": "2",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Stap op Bijlmer ArenA over op NS-treinen naar Zuid of op Spaklerweg voor Overamstel. Details: gvb.nl --|Change at Bijlmer ArenA for NS train",
|
||||
"descriptionText": "Stap op Bijlmer ArenA over op NS-treinen naar Zuid of op Spaklerweg voor Overamstel. Details: gvb.nl --|Change at Bijlmer ArenA for NS trains to Zuid or at Spaklerweg for Overamstel. Details: gvb.nl"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"SUBWAY",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amsterdam, Station Holendrecht",
|
||||
"stopId": "nl-OpenOV_3981303",
|
||||
"parentId": "nl-OpenOV_NL:S:30009526",
|
||||
"importance": 0.026576673611998558,
|
||||
"lat": 52.29839300000001,
|
||||
"lon": 4.959859000000001,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:28:00Z",
|
||||
"departure": "2026-08-16T21:29:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:28:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:29:00Z",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Stap op Bijlmer ArenA over op NS-treinen naar Zuid of op Spaklerweg voor Overamstel. Details: gvb.nl --|Change at Bijlmer ArenA for NS train",
|
||||
"descriptionText": "Stap op Bijlmer ArenA over op NS-treinen naar Zuid of op Spaklerweg voor Overamstel. Details: gvb.nl --|Change at Bijlmer ArenA for NS trains to Zuid or at Spaklerweg for Overamstel. Details: gvb.nl"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"REGIONAL_RAIL",
|
||||
"SUBWAY",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amsterdam, Bullewijk",
|
||||
"stopId": "nl-OpenOV_3981144",
|
||||
"parentId": "nl-OpenOV_NL:S:30009524",
|
||||
"importance": 0.017878592014312744,
|
||||
"lat": 52.30693,
|
||||
"lon": 4.952079,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:30:00Z",
|
||||
"departure": "2026-08-16T21:31:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:30:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:31:00Z",
|
||||
"scheduledTrack": "2",
|
||||
"track": "2",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Stap op Bijlmer ArenA over op NS-treinen naar Zuid of op Spaklerweg voor Overamstel. Details: gvb.nl --|Change at Bijlmer ArenA for NS train",
|
||||
"descriptionText": "Stap op Bijlmer ArenA over op NS-treinen naar Zuid of op Spaklerweg voor Overamstel. Details: gvb.nl --|Change at Bijlmer ArenA for NS trains to Zuid or at Spaklerweg for Overamstel. Details: gvb.nl"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"SUBWAY"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amsterdam, Station Bijlmer ArenA",
|
||||
"stopId": "nl-OpenOV_3980962",
|
||||
"parentId": "nl-OpenOV_NL:S:30009522",
|
||||
"importance": 0.04671318084001541,
|
||||
"lat": 52.31261,
|
||||
"lon": 4.946935,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:32:00Z",
|
||||
"departure": "2026-08-16T21:32:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:32:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:32:00Z",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Neem NS-treinen naar Zuid of stap op Spaklerweg over voor Overamstel. Details: gvb.nl/zomermetro --|Take NS trains to Zuid or change at Spak",
|
||||
"descriptionText": "Neem NS-treinen naar Zuid of stap op Spaklerweg over voor Overamstel. Details: gvb.nl/zomermetro --|Take NS trains to Zuid or change at Spaklerweg for Overamstel. Details: gvb.nl/summermetro"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"LONG_DISTANCE",
|
||||
"COACH",
|
||||
"NIGHT_RAIL",
|
||||
"REGIONAL_RAIL",
|
||||
"SUBWAY",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amsterdam, Strandvliet",
|
||||
"stopId": "nl-OpenOV_3981329",
|
||||
"parentId": "nl-OpenOV_NL:S:30009520",
|
||||
"importance": 0.017878592014312744,
|
||||
"lat": 52.31868399999999,
|
||||
"lon": 4.941375000000001,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:33:00Z",
|
||||
"departure": "2026-08-16T21:34:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:33:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:34:00Z",
|
||||
"scheduledTrack": "2",
|
||||
"track": "2",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Stap op Spaklerweg over voor Overamstel of op Amstel voor RAI. Details: gvb.nl/zomermetro –|Change at Spaklerweg for Overamstel or at Amstel",
|
||||
"descriptionText": "Stap op Spaklerweg over voor Overamstel of op Amstel voor RAI. Details: gvb.nl/zomermetro –|Change at Spaklerweg for Overamstel or at Amstel for RAI. Details: gvb.nl/summermetro"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"SUBWAY"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Duivendrecht, Station Duivendrecht",
|
||||
"stopId": "nl-OpenOV_3979812",
|
||||
"parentId": "nl-OpenOV_NL:S:30009551",
|
||||
"importance": 0.018022045493125916,
|
||||
"lat": 52.323895,
|
||||
"lon": 4.936262000000002,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:35:00Z",
|
||||
"departure": "2026-08-16T21:35:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:35:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:35:00Z",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-09T15:42:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-09T15:42:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Neem NS-treinen naar Zuid of stap op Spaklerweg over voor Overamstel. Details: gvb.nl/zomermetro --|Take NS trains to Zuid or change at Spak",
|
||||
"descriptionText": "Neem NS-treinen naar Zuid of stap op Spaklerweg over voor Overamstel. Details: gvb.nl/zomermetro --|Take NS trains to Zuid or change at Spaklerweg for Overamstel. Details: gvb.nl/summermetro"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"SUBWAY",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amsterdam, Van der Madeweg",
|
||||
"stopId": "nl-OpenOV_3981387",
|
||||
"parentId": "nl-OpenOV_NL:S:30009516",
|
||||
"importance": 0.0271453857421875,
|
||||
"lat": 52.329903,
|
||||
"lon": 4.930333000000001,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:36:00Z",
|
||||
"departure": "2026-08-16T21:37:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:36:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:37:00Z",
|
||||
"scheduledTrack": "2",
|
||||
"track": "2",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Stap op Spaklerweg over voor Overamstel of op Amstel voor RAI. Details: gvb.nl/zomermetro –|Change at Spaklerweg for Overamstel or at Amstel",
|
||||
"descriptionText": "Stap op Spaklerweg over voor Overamstel of op Amstel voor RAI. Details: gvb.nl/zomermetro –|Change at Spaklerweg for Overamstel or at Amstel for RAI. Details: gvb.nl/summermetro"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"SUBWAY",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amsterdam, Spaklerweg",
|
||||
"stopId": "nl-OpenOV_3980158",
|
||||
"parentId": "nl-OpenOV_NL:S:30009518",
|
||||
"importance": 0.02681240625679493,
|
||||
"lat": 52.340239999999994,
|
||||
"lon": 4.92107,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:39:00Z",
|
||||
"departure": "2026-08-16T21:39:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:39:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:39:00Z",
|
||||
"scheduledTrack": "2",
|
||||
"track": "2",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"modes": [
|
||||
"SUBWAY",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amsterdam, Amstelstation",
|
||||
"stopId": "nl-OpenOV_3980520",
|
||||
"parentId": "nl-OpenOV_NL:S:30009514",
|
||||
"importance": 0.052102621644735336,
|
||||
"lat": 52.346489999999996,
|
||||
"lon": 4.9175699999999996,
|
||||
"level": 0.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:40:00Z",
|
||||
"departure": "2026-08-16T21:41:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:40:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:41:00Z",
|
||||
"scheduledTrack": "2",
|
||||
"track": "2",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"alerts": [
|
||||
{
|
||||
"communicationPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"impactPeriod": [
|
||||
{
|
||||
"start": "2026-07-04T04:00:00Z",
|
||||
"end": "2026-08-16T23:00:00Z"
|
||||
}
|
||||
],
|
||||
"cause": "UNKNOWN_CAUSE",
|
||||
"effect": "UNKNOWN_EFFECT",
|
||||
"headerText": "Neem tram 23 voor RAI of bus 78 voor Overamstel. Details: gvb.nl/zomermetro. --|Take tram 23 for RAI or bus 78 for Overamstel. Details: gvb.nl/summermetro",
|
||||
"descriptionText": "Neem tram 23 voor RAI of bus 78 voor Overamstel. Details: gvb.nl/zomermetro. --|Take tram 23 for RAI or bus 78 for Overamstel. Details: gvb.nl/summermetro"
|
||||
}
|
||||
],
|
||||
"modes": [
|
||||
"REGIONAL_RAIL",
|
||||
"SUBWAY",
|
||||
"TRAM",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amsterdam, Wibautstraat",
|
||||
"stopId": "nl-OpenOV_3980265",
|
||||
"parentId": "nl-OpenOV_NL:S:30009512",
|
||||
"importance": 0.027183683589100838,
|
||||
"lat": 52.354332,
|
||||
"lon": 4.9121879999999996,
|
||||
"level": -2.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:42:00Z",
|
||||
"departure": "2026-08-16T21:43:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:42:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:43:00Z",
|
||||
"scheduledTrack": "2",
|
||||
"track": "2",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"modes": [
|
||||
"SUBWAY",
|
||||
"TRAM",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amsterdam, Weesperplein",
|
||||
"stopId": "nl-OpenOV_3981336",
|
||||
"parentId": "nl-OpenOV_NL:S:30009510",
|
||||
"importance": 0.02798701450228691,
|
||||
"lat": 52.361236999999996,
|
||||
"lon": 4.907945,
|
||||
"level": -2.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:44:00Z",
|
||||
"departure": "2026-08-16T21:45:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:44:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:45:00Z",
|
||||
"scheduledTrack": "2",
|
||||
"track": "2",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"modes": [
|
||||
"SUBWAY",
|
||||
"TRAM",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amsterdam, Waterlooplein",
|
||||
"stopId": "nl-OpenOV_3980640",
|
||||
"parentId": "nl-OpenOV_NL:S:30009508",
|
||||
"importance": 0.027206193655729294,
|
||||
"lat": 52.366985,
|
||||
"lon": 4.9031400000000005,
|
||||
"level": -2.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:46:00Z",
|
||||
"departure": "2026-08-16T21:46:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:46:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:46:00Z",
|
||||
"scheduledTrack": "2",
|
||||
"track": "2",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"modes": [
|
||||
"SUBWAY",
|
||||
"TRAM",
|
||||
"BUS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amsterdam, Nieuwmarkt",
|
||||
"stopId": "nl-OpenOV_3981245",
|
||||
"parentId": "nl-OpenOV_NL:S:30009502",
|
||||
"importance": 0.02679653838276863,
|
||||
"lat": 52.371914,
|
||||
"lon": 4.901427000000001,
|
||||
"level": -2.0,
|
||||
"tz": "Europe/Brussels",
|
||||
"arrival": "2026-08-16T21:47:00Z",
|
||||
"departure": "2026-08-16T21:47:00Z",
|
||||
"scheduledArrival": "2026-08-16T21:47:00Z",
|
||||
"scheduledDeparture": "2026-08-16T21:47:00Z",
|
||||
"scheduledTrack": "2",
|
||||
"track": "2",
|
||||
"vertexType": "TRANSIT",
|
||||
"pickupType": "NORMAL",
|
||||
"dropoffType": "NORMAL",
|
||||
"cancelled": false,
|
||||
"modes": [
|
||||
"SUBWAY"
|
||||
]
|
||||
}
|
||||
],
|
||||
"legGeometry": {
|
||||
"points": "wd|vbBornoHN??fGFxa@?hQOzU?jFwBhF}ApDVhFLhg@^bi@LnDM|UBno@?bIKj[?vATxl@NpNFz`@D~j@?z`@?f]Fpd@Vl[?dGLjQVdGL|Jb@hQHlE\\rMnAxVb@|Id@jFzAlZhAtLv@vLjAvL?ZbAvLf@fGfBbSjAtL~Ib~@rClZvAjPd@lEdH|t@tCf\\`C`Tr@zJd@hFdA`IpBhQxBhQjEpX|AvLpCpXjCtWbGpn@zHnz@fBdR|A`TjAbSd@tWVvLWtM[`T_@|JmAhQaBhRcClP_DjQsEbT_DzKoD|JkD~J{E|J}BnE_DfG_DjFkBtBkFfHeFhGiGhGqHjG{OzLee@t\\{PvN_SnQW?eAz@cj@xf@g@\\aAz@a@ZgAz@e@z@u@\\qHfHap@nj@u@z@kAx@O\\e@?qCrCse@bc@_SrP_IdIiXbVeJdI}OxMsAxAaRpPmBxAq@x@q@z@yIdIkGjFeOxMuJbJ}Az@kCrCg@\\sAvA{HjGgGhGO?wXbV{FlFoWfU_d@h`@s`@p]oUjSsOzMoCrCkAx@_DrCeEpDmGfHmh@|e@sAx@kAz@sAvAyAxA{p@lk@u@z@q@z@gEpDsDrCkCrCw`@r\\{JbJqk@vg@qBxAil@pi@yBvBce@db@wAz@mBvA]z@u@\\e@x@u@\\cAz@}OvNwDpDaf@db@sAxAaCtBcTnQS\\}XfUqHjG{BtBaAz@u@x@oCvBkFnEwGfHw`@p]gb@l_@wX`Wu@\\iAvAkBxAgAz@wAvAqRlRgYdVyNvN_DrC?\\K?{FnEgSrPO?cj@|e@oDnDsBxAgm@ri@yAvAuJfI{f@bc@iBtBiAz@u@\\iBtBccA~}@wL`Kcj@th@wVbW_UfT_VdVwAvAgAz@eXxYoCtCyBrCW?k@x@u@z@{FhGkBrCeSjSmGhGuNtO{L~KqLbJoCtBeKbJkK`K_}@lcAW\\cAz@cAvA}@z@sAvAsj@hm@}h@th@wIbJwFjFuWt[}O~KcP`L_DrC{@x@w@z@_DrCcAz@cAz@kAx@gAxAsEpDU\\{AvAgc@l_@iUlSce@ha@_IdImBtByFnEea@r]e@z@wAx@kM|LaCtByBtBsCvBkCtByNzMa@Z}BvBuJdIs`@r]sj@|e@kiC`~Bos@hn@sfAxaA_\\~XoUlR{PzMcVnRyLfIqBz@iCxAqGpDeU|M}bAxj@eA\\{Az@ac@pSwBvAsEvBwDrCcIrDwBxAiA\\_DxAyIrDgOjG_MvCuNpEyUvDgJvCkGxAkNhHcj@fXk_Adf@gJnFiZvOkKnFyBz@ya@pSWZ}^rRyQ|LyIjG_M~Kkl@nk@sb@fb@wVfUaP~L}P~KwMlGuOdJqVhIwSpFcWfJgLpEwLnFgOjHga@nSsFtCs{@lc@eS~LaR~KkdA`i@ia@pSihA~i@c`@nScJpEgGrDcV`Li}@df@uOhIwf@dXsWzNwDxA_MnFmQjHufBxz@oLnFea@fWuJjGiGlFmFlF_DpDmDpDqCnDeEjGeEhG_ItNsY|o@sMvYgZzp@yF|KuE|KkF|KwG|KyDhGyBrC{FfHaHfHmLbKkHnE}FtCoDxAeExAeE|@iBz@mFz@eJ|@_Nb@oG@{JsA_DYeIuAiLgE_TyI_o@aY_NcGkCuAoAy@sEoC_HiEsDqCsIaHoJyJyBoCeEiFcMgPu^kc@qL{IqM_H}S_HkKqAqMsAaNDgSxCoHxAcMtDsR~LkHfHsCpDiBtBaAvBY?sAtBqEfHcCrCuE~Ja@z@oGvM_DdI_IdTkVnt@wBqC",
|
||||
"precision": 6,
|
||||
"length": 430
|
||||
},
|
||||
"bikesAllowed": true,
|
||||
"wheelchairAccessible": "NOT_ACCESSIBLE",
|
||||
"reservation": "NONE",
|
||||
"alternatives": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,21 +21,19 @@ if len(sys.argv) > 1:
|
||||
async def on_ready():
|
||||
global _bot_initialized
|
||||
|
||||
logger(f"{bot.user} ist online")
|
||||
|
||||
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)
|
||||
else:
|
||||
logger("Reconnected to discord gateway, this wont disturb your current ride")
|
||||
|
||||
try:
|
||||
bot.run(config.discord.token)
|
||||
except:
|
||||
logger("Feher peim parsen des tokens", "fatal")
|
||||
logger("An error occured while parsing the token", "fatal")
|
||||
|
||||
'''
|
||||
TODO
|
||||
|
||||
@@ -13,6 +13,8 @@ numpy==2.5.1
|
||||
pandas==3.0.3
|
||||
propcache==0.5.2
|
||||
py-cord==2.8.0
|
||||
py-staticmaps[cairo]==0.5.0
|
||||
pyyaml==6.0.3
|
||||
pycparser==3.0
|
||||
pynacl==1.6.2
|
||||
python-dateutil==2.9.0.post0
|
||||
|
||||
@@ -3,10 +3,13 @@ import random
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.utils import logger, get_train_name, convert_iso_string, validate_connection
|
||||
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
|
||||
|
||||
station_names = config.connections.stations
|
||||
station_IDs = config.connections.IDs
|
||||
blacklist = config.connections.blacklist
|
||||
user_agent = config.http.user_agent
|
||||
|
||||
@@ -17,12 +20,11 @@ headers = {
|
||||
endpoint = "https://api.transitous.org"
|
||||
|
||||
def check_stations():
|
||||
logger(f"Testing {len(station_names)} stations...")
|
||||
all_stations_output = {}
|
||||
minimal_overview = {
|
||||
"stations": {}
|
||||
}
|
||||
minimal_overview = {}
|
||||
|
||||
for station in stations:
|
||||
for station in station_names:
|
||||
req = f"{endpoint}/api/v1/geocode"
|
||||
|
||||
try:
|
||||
@@ -50,8 +52,8 @@ def check_stations():
|
||||
"tz": entry.get("tz"),
|
||||
"country": entry.get("country"),
|
||||
"coords": coords,
|
||||
"modes": modes,
|
||||
"id": station_id,
|
||||
"modes": modes,
|
||||
}
|
||||
|
||||
if stop_name:
|
||||
@@ -60,7 +62,6 @@ def check_stations():
|
||||
|
||||
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:
|
||||
@@ -68,10 +69,14 @@ def check_stations():
|
||||
continue
|
||||
|
||||
all_stations_output[station] = {
|
||||
"exact_match": exact_match,
|
||||
"associated": stops_dict
|
||||
}
|
||||
|
||||
minimal_overview["stations"][station] = aliases_list
|
||||
minimal_overview[station] = {
|
||||
"exact_match": exact_match,
|
||||
"names": aliases_list
|
||||
}
|
||||
|
||||
logger(json.dumps(minimal_overview, indent=4, ensure_ascii=False))
|
||||
|
||||
@@ -88,7 +93,26 @@ def check_stations():
|
||||
|
||||
|
||||
def get_random_stop_id() -> str | None:
|
||||
assigned_station = random.choice(stations)
|
||||
stations = {}
|
||||
|
||||
if len(station_names) > 0:
|
||||
for entry in station_names:
|
||||
if entry != "":
|
||||
stations[entry] = "name"
|
||||
|
||||
if len(station_IDs) > 0:
|
||||
for entry in station_IDs:
|
||||
if entry != "":
|
||||
stations[entry] = "ID"
|
||||
|
||||
if not stations:
|
||||
logger("Both stations and IDs are empty...", "FATAL")
|
||||
|
||||
assigned_station = random.choice(list(stations.keys()))
|
||||
|
||||
if stations[assigned_station] == "ID":
|
||||
return assigned_station
|
||||
|
||||
req = f"{endpoint}/api/v1/geocode"
|
||||
|
||||
try:
|
||||
@@ -118,11 +142,12 @@ def get_random_stop_id() -> str | None:
|
||||
return None
|
||||
|
||||
stop_ids_list = list(stop_ids.keys())
|
||||
stops_string = ", ".join(stop_ids.values())
|
||||
if len(stop_ids_list) > 1:
|
||||
logger(f"Station '{assigned_station}' not found, choosing random from available: {stops_string}")
|
||||
logger(f"No station associated as '{assigned_station}', choosing random from available")
|
||||
chosen_stop_id = random.choice(stop_ids_list)
|
||||
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")
|
||||
logger(f"Selected station: {stop_ids[chosen_stop_id]}")
|
||||
|
||||
return chosen_stop_id
|
||||
|
||||
def get_random_connection(stop_id: str) -> str | None:
|
||||
@@ -130,20 +155,7 @@ def get_random_connection(stop_id: str) -> str | 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
|
||||
params = {"stopId": stop_id, "n": 50, "time": now}
|
||||
|
||||
try:
|
||||
response = requests.get(f"{endpoint}/api/v5/stoptimes", params=params, headers=headers)
|
||||
@@ -151,39 +163,38 @@ def get_random_connection(stop_id: str) -> str | None:
|
||||
data = response.json()
|
||||
except requests.RequestException as e:
|
||||
logger(e, "Error")
|
||||
break
|
||||
return None
|
||||
|
||||
stop_times = data.get("stopTimes", [])
|
||||
all_stop_times.extend(stop_times)
|
||||
|
||||
for entry in stop_times:
|
||||
trip_id = entry["tripId"]
|
||||
candidates = []
|
||||
for entry in data.get("stopTimes", []):
|
||||
if entry["mode"] in blacklist:
|
||||
continue
|
||||
else:
|
||||
trip_ids.append(entry["tripId"])
|
||||
candidates.append(entry)
|
||||
|
||||
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")
|
||||
if not candidates:
|
||||
logger("Couldn't find any connections", "error")
|
||||
return None
|
||||
trip_id = random.choice(trip_ids)
|
||||
|
||||
from_station = None
|
||||
for entry in all_stop_times:
|
||||
if entry.get("tripId") == trip_id:
|
||||
from_station = entry.get("place", {}).get("name")
|
||||
break
|
||||
if config.connections.priority:
|
||||
best_priority = None
|
||||
for entry in candidates:
|
||||
priority = config.connections.priority.get(entry["mode"], 99)
|
||||
if best_priority is None or priority < best_priority:
|
||||
best_priority = priority
|
||||
|
||||
best_candidates = []
|
||||
for entry in candidates:
|
||||
priority = config.connections.priority.get(entry["mode"], 99)
|
||||
if priority == best_priority:
|
||||
best_candidates.append(entry)
|
||||
|
||||
chosen = random.choice(best_candidates)
|
||||
else:
|
||||
chosen = random.choice(candidates)
|
||||
|
||||
return {
|
||||
"trip_id": trip_id,
|
||||
"from_station": from_station
|
||||
"trip_id": chosen["tripId"],
|
||||
"from_station": chosen.get("place", {}).get("name"),
|
||||
}
|
||||
|
||||
def get_trip_details(random_connection: dict | None) -> dict | None:
|
||||
@@ -200,6 +211,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")
|
||||
return None
|
||||
|
||||
|
||||
legs = data["legs"][0]
|
||||
end_time = legs["endTime"]
|
||||
from_station = random_connection["from_station"]
|
||||
@@ -209,45 +221,78 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
|
||||
start_time = legs["startTime"]
|
||||
mode = legs["mode"]
|
||||
|
||||
arrival = convert_iso_string(end_time)
|
||||
departure = convert_iso_string(start_time)
|
||||
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
|
||||
|
||||
train_name = get_train_name(display_name, mode)
|
||||
if train_from == from_station:
|
||||
long_name = f"{train_name} nach {goes_to} von {train_from}"
|
||||
all_stop_names = [train_from] + [stop["name"] for stop in legs["intermediateStops"]] + [goes_to]
|
||||
if len(all_stop_names) != len(set(all_stop_names)):
|
||||
logger("Route reuses station names! Skipping, to be safe...", "error")
|
||||
return None # TODO this too
|
||||
|
||||
arrival_dt = parse_iso(end_time)
|
||||
departure_dt = parse_iso(start_time)
|
||||
|
||||
train_name = get_train_name(display_name, mode) # used by lang
|
||||
if len(lang.channel.long_name.train_from()) >= 100:
|
||||
channel_name = lang.channel.short_name
|
||||
else:
|
||||
long_name = f"{train_name} nach {goes_to} über {from_station}"
|
||||
if train_from == from_station:
|
||||
channel_name = lang.channel.long_name.train_from()
|
||||
else:
|
||||
channel_name = lang.channel.long_name.train_via()
|
||||
|
||||
trip_details = {
|
||||
"long_name": long_name,
|
||||
"short_name": display_name,
|
||||
"channel_name": channel_name,
|
||||
"station": from_station,
|
||||
"from": train_from,
|
||||
"to": goes_to,
|
||||
"agency": legs["agencyName"],
|
||||
"route_color": legs.get("routeColor"),
|
||||
"duration": legs["duration"],
|
||||
"departure": departure,
|
||||
"arrival": arrival,
|
||||
"departure": departure_dt.strftime("%H:%M"),
|
||||
"departure_dt": departure_dt,
|
||||
"arrival": arrival_dt.strftime("%H:%M"),
|
||||
"arrival_dt": arrival_dt,
|
||||
"mode": mode,
|
||||
"stops": {}
|
||||
}
|
||||
|
||||
trip_details["stops"][train_from] = departure
|
||||
departure_time = start_time
|
||||
train_from_importance = legs["from"]["importance"]
|
||||
trip_details["stops"][train_from] = {
|
||||
"arrival": departure_dt,
|
||||
"importance": train_from_importance,
|
||||
"lat": legs["from"]["lat"],
|
||||
"lon": legs["from"]["lon"]
|
||||
}
|
||||
|
||||
departure_time_iso = start_time
|
||||
|
||||
for stop in legs["intermediateStops"]:
|
||||
stop_arrival = convert_iso_string(stop["arrival"])
|
||||
trip_details["stops"][stop["name"]] = stop_arrival # not sure if that actually works but im too tired to question it
|
||||
if stop.get("name") == from_station:
|
||||
departure_time = stop["departure"]
|
||||
trip_details["departure"] = convert_iso_string(departure_time)
|
||||
trip_details["stops"][goes_to] = arrival
|
||||
stop_arrival_dt = parse_iso(stop["arrival"])
|
||||
stop_importance = stop["importance"]
|
||||
stop_details = {
|
||||
"arrival": stop_arrival_dt,
|
||||
"importance": stop_importance,
|
||||
"lat": stop["lat"],
|
||||
"lon": stop["lon"]
|
||||
}
|
||||
trip_details["stops"][stop["name"]] = stop_details
|
||||
|
||||
valid = validate_connection(start_time, end_time, departure_time)
|
||||
if stop.get("name") == from_station:
|
||||
departure_time_iso = stop["departure"]
|
||||
trip_details["departure"] = parse_iso(departure_time_iso).strftime("%H:%M")
|
||||
|
||||
train_to_importance = legs["to"]["importance"]
|
||||
trip_details["stops"][goes_to] = {
|
||||
"arrival": arrival_dt,
|
||||
"importance": train_to_importance,
|
||||
"lat": legs["to"]["lat"],
|
||||
"lon": legs["to"]["lon"]
|
||||
}
|
||||
|
||||
valid = validate_connection(start_time, end_time, departure_time_iso)
|
||||
if not valid:
|
||||
return None
|
||||
|
||||
logger(json.dumps(trip_details, indent=4, ensure_ascii=False))
|
||||
return trip_details
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
@@ -8,27 +7,26 @@ class DiscordConfig:
|
||||
token: str
|
||||
server: int
|
||||
vc: int
|
||||
lang: str
|
||||
formatting: str
|
||||
emojis: bool
|
||||
|
||||
@dataclass
|
||||
class ConnectionsConfig:
|
||||
stations: list[str]
|
||||
IDs: list[str]
|
||||
priority: dict
|
||||
blacklist: list[str]
|
||||
min_duration: int
|
||||
max_wait_time: int
|
||||
timezone: str
|
||||
max_wait_time: Optional[int] = None
|
||||
max_duration: Optional[int] = None
|
||||
|
||||
@dataclass
|
||||
class VoiceAnnouncementConfig:
|
||||
enabled: bool
|
||||
stations: dict[str, str]
|
||||
|
||||
@dataclass
|
||||
class AnnouncementConfig:
|
||||
enabled: bool
|
||||
voice: list[VoiceAnnouncementConfig]
|
||||
text_announcements: bool
|
||||
voice_announcements: bool
|
||||
map: bool
|
||||
|
||||
@dataclass
|
||||
class HttpConfig:
|
||||
@@ -48,10 +46,7 @@ def _load_config() -> Config:
|
||||
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"]],
|
||||
),
|
||||
announcements=AnnouncementConfig(**raw["announcements"]),
|
||||
http=HttpConfig(**raw["http"]),
|
||||
)
|
||||
|
||||
|
||||
|
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
|
||||
@@ -7,5 +7,6 @@ emoji_list = {
|
||||
"HIGHSPEED_RAIL": "🚅",
|
||||
"LONG_DISTANCE": "🚅",
|
||||
"METRO": "🚇",
|
||||
"SUBWAY": "🚇"
|
||||
"SUBWAY": "🚇",
|
||||
"FERRY": "🚢"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
channel:
|
||||
long_name:
|
||||
train_from: "{train_name} nach {goes_to} von {train_from}"
|
||||
train_via: "{train_name} nach {goes_to} über {from_station}"
|
||||
short_name: "{train_name} nach {goes_to}"
|
||||
embeds:
|
||||
info:
|
||||
description: "Abfahrt von {station} um {departure}. Ankunft um {arrival}"
|
||||
via: "Über"
|
||||
"via_and": "und"
|
||||
"next_stop": "Nächster Halt"
|
||||
announcement:
|
||||
title: "Informationen zu ihrer Fahrt"
|
||||
"end_of_connection":
|
||||
message: "Sehr geehrte Fahrgäste,\nIn wenigen Minuten erreichen wir {destination}. Unsere Reise endet dort\n\nWir wünschen Ihnen eine angenehme Weiterreise.\n\nVielen Dank für ihr Vertrauen und auf Wiedersehen."
|
||||
rich_presence:
|
||||
arrival: "Ankunft um {arrival} • /info"
|
||||
@@ -0,0 +1,17 @@
|
||||
channel:
|
||||
long_name:
|
||||
train_from: "{train_name} to {goes_to} from {train_from}"
|
||||
train_via: "{train_name} to {goes_to} via {from_station}"
|
||||
short_name: "{train_name} to {goes_to}"
|
||||
embeds:
|
||||
info:
|
||||
description: "Departure from {station} at {departure}. Arrival by {arrival}"
|
||||
via: "via"
|
||||
"via_and": "and"
|
||||
"next_stop": "Next stop"
|
||||
announcement:
|
||||
title: "Information about your trip"
|
||||
"end_of_connection":
|
||||
message: "Dear Passengers,\nwe will be arriving at {destination} in a few minutes. Our journey ends there.\n\nWe wish you a pleasant onward journey.\n\nThank you for your patronage, and goodbye."
|
||||
rich_presence:
|
||||
arrival: "Arrival by {arrival} • /info"
|
||||
@@ -1,121 +1,171 @@
|
||||
''''
|
||||
Die Datei gibt dem Embed zugehörige Metadaten der Zugbetreiber. Identifiziert wird dabei durch die genauen Namen der Unternehmen.
|
||||
Diese werden sowohl im /info Embed als auch in der Konsole angezeigt.
|
||||
|
||||
| Feld | Optional | Wert | Beschreibung |
|
||||
|---------|----------|-------|-----------------------------------------------------------------------|
|
||||
| Unknown | Ja | Bool | Eigentlich nur für fallback relevant, sollte man weglassen |
|
||||
| Logo | Nein | URL | Das Logo in der Ecke des Embeds. Am besten PNG, SVG geht leider nicht |
|
||||
| Color | Nein | HEX | HEX Color Code des Streifen vom Embed, muss mit 0x anfangen |
|
||||
| Slogan | Ja | Liste | Zufälliger Text aus der Liste im Footer |
|
||||
|
||||
Sollte es dazu kommen, dass mehrere Anbieter die selben Metadaten nutzen sollen, kann man einen Eintrag für alle in OPERATORS setzen und
|
||||
die zugehörigen Anbieternamen in OPERATOR_ALIASES auf den Eintrag lenken lassen. db_bawü ist dafür ein gutes Beispiel.
|
||||
|
||||
Nachdem diese Datei editiert wurde muss der Bot nicht neugestartet werden.
|
||||
'''
|
||||
OPERATORS = {
|
||||
"fallback": {
|
||||
"unknown": True,
|
||||
"logo": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQCVeC5E0mNNBKyQftQaFMzxIVkbDvEnSzWv07h_c8PdA&s=10",
|
||||
"color": 0xFFFFFF
|
||||
"Abellio Rail Mitteldeutschland GmbH": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Abellio_logo.svg/1920px-Abellio_logo.svg.png",
|
||||
"color": 0xD7002E,
|
||||
"slogan": ["Freie Bahn für deine Ziele!"]
|
||||
},
|
||||
"Agilis": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Agilis_Logo.svg/960px-Agilis_Logo.svg.png",
|
||||
"color": 0xCAE15B,
|
||||
"slogan": ["Reinsetzen, zurücklehnen", "Fahren Sie mit uns. Nehmen Sie Platz."]
|
||||
},
|
||||
"Aktiv Bus Flensburg GmbH": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/26/Aktiv_Bus_Flensburg_GmbH_logo.svg/960px-Aktiv_Bus_Flensburg_GmbH_logo.svg.png?utm_source=de.wikipedia.org&utm_campaign=index&utm_content=thumbnail",
|
||||
"color": 0xFFC331
|
||||
},
|
||||
"Arverio Bayern": {
|
||||
"logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528006572805062776/Arverio_Avi_Bayern_blau_RGB.png?ex=6a5cba83&is=6a5b6903&hm=5781ae6c372c92ab4f52409c6fc91e5014ac34ccf24db665ade052e8135bdde0&animated=true",
|
||||
"color": 0x0083BE,
|
||||
"slogan": ["Dein Weg ist unser Ziel."]
|
||||
},
|
||||
"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,
|
||||
"slogan": ["Weil wir dich lieben"]
|
||||
},
|
||||
"DSB": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/DSB_company_logo.svg/960px-DSB_company_logo.svg.png",
|
||||
"color": 0xB22B32,
|
||||
"slogan": ["Tak for at vise hensyn"]
|
||||
},
|
||||
"enno": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Enno_logo.svg/960px-Enno_logo.svg.png",
|
||||
"color": 0x88216F,
|
||||
"slogan": ["enno fährt bunt"]
|
||||
},
|
||||
"Erfurter Bahn": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/Erfurter_Bahn_logo.svg/960px-Erfurter_Bahn_logo.svg.png",
|
||||
"color": 0x009133,
|
||||
"slogan": ["Kommt gut an."]
|
||||
},
|
||||
"eurobahn": {
|
||||
"logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528405390260179146/Eurpnajm.png?ex=6a5e2df1&is=6a5cdc71&hm=d73fc3458f59f8942a5a0f51311308b12567b35b55b64aab0726bf6be92b3b0e&animated=true",
|
||||
"color": 0x005a9b,
|
||||
"slogan": ["Echte Menschen. Echt schöne Ziele."]
|
||||
},
|
||||
"European Sleeper": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/European_Sleeper_Logo.svg/960px-European_Sleeper_Logo.svg.png",
|
||||
"color": 0xEB4A27,
|
||||
"slogan": ["The Good Night Train", "Travel while you sleep"]
|
||||
},
|
||||
"Flixbus": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Flixbus_201x_logo.svg/1280px-Flixbus_201x_logo.svg.png",
|
||||
"color": 0x8CD541,
|
||||
"slogan": ["Einfach Busfahren.", "Fahr grün!", "FLIX it", "Mehr Leben im echten Leben"]
|
||||
},
|
||||
"GoVolta": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/GoVolta_logo.svg/1280px-GoVolta_logo.svg.png",
|
||||
"color": 0x0B70F6,
|
||||
"slogan": ["Internationale Züge, endlich erschwinglich!", "International trains, finally affordable!"]
|
||||
},
|
||||
"GVB": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/BSicon_LOGO_GVB.svg/960px-BSicon_LOGO_GVB.svg.png",
|
||||
"color": 0x2B62AF,
|
||||
},
|
||||
"Jenaer Nahverkehr GmbH": {
|
||||
"logo": "https://www.stadtwerke-jena.de/dam/jcr:74dc9d81-0447-4864-973b-7e395f1d6948/JNV_Bildmarke_322px.png",
|
||||
"color": 0x0075BD
|
||||
},
|
||||
"Nordbahn Eisenbahngesellschaft": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/b/b5/Logo_Nordbahn_NAH.SH_Blau_positiv_final.png",
|
||||
"color": 0x1A2848,
|
||||
"slogan": ["nordbahn kommt von Norden", "Bringt den Norden näher", "Wir transportieren das Biest"]
|
||||
},
|
||||
"NS": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Logo_NS.svg/960px-Logo_NS.svg.png",
|
||||
"color": 0x00337F,
|
||||
"slogan": ["Goed op weg", "Welkom in de trein van morgen", "Veilig, Vlug, Voordelig", "we hebben een serieus probleem", "Neuken in de keuken"]
|
||||
},
|
||||
"Ostdeutsche Eisenbahn GmbH": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/ODEG-Logo_Neu.svg/960px-ODEG-Logo_Neu.svg.png",
|
||||
"color": 0x00745C,
|
||||
"slogan": ["Rücksicht kommt gut", "Benimm dich, als wäre Mutti dabei.", "Sicher mit der Sachsenschleuder"]
|
||||
},
|
||||
"Regionalverkehre Start Deutschland GmbH (Start Mitteldeutschland)": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/Logo_der_Regionalverkehre_Start_Deutschland.svg/960px-Logo_der_Regionalverkehre_Start_Deutschland.svg.png",
|
||||
"color": 0x61A731,
|
||||
},
|
||||
"S-Bahn": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e7/S-Bahn-Logo.svg/500px-S-Bahn-Logo.svg.png",
|
||||
"color": 0x3E8B55
|
||||
},
|
||||
"S-Bahn Hannover (Transdev)": {
|
||||
"logo": "https://cdn.discordapp.com/attachments/1526274436523888732/1528470315234234520/sbahn.png?ex=6a5e6a68&is=6a5d18e8&hm=c9f630c6f0c646eafeccef9b4d591ce8fcf8856b2b45f14e72fbf25e32edb017&animated=true",
|
||||
"color": 0x1A4389,
|
||||
"slogan": ["Wir geben alles", "Wir bringen dich", "Wir bringen Respekt"]
|
||||
},
|
||||
"SBB": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/SBB_logo_simplified.svg/960px-SBB_logo_simplified.svg.png",
|
||||
"color": 0xEB0000,
|
||||
"slogan": ["Der Kluge reist im Zuge", "SBB schafft Verbindungen", "Unterwegs zuhause"]
|
||||
},
|
||||
"SNCF": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/en/thumb/f/f4/Sncf-logo.svg/960px-Sncf-logo.svg.png",
|
||||
"color": 0x812B6D,
|
||||
"slogan": ["Pour nous tous", "Donner au train des idées d'avance"]
|
||||
},
|
||||
"Stadtverkehr Tübingen GmbH": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/14/Stadtwerke_T%C3%BCbingen_logo.svg/330px-Stadtwerke_T%C3%BCbingen_logo.svg.png",
|
||||
"color": 0x439669
|
||||
},
|
||||
"Südwestdeutsche Verkehrs-AG": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/SWEG_Logo.svg/330px-SWEG_Logo.svg.png?utm_source=de.wikipedia.org&utm_campaign=index&utm_content=thumbnail",
|
||||
"color": 0x1A509A
|
||||
},
|
||||
"Tallink Grupp AS": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/Tallink_logo.svg/330px-Tallink_logo.svg.png",
|
||||
"color": 0x225197
|
||||
},
|
||||
"Verkehrsbetrieb Potsdam GmbH": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/Verkehrsbetrieb_Potsdam_logo.svg/330px-Verkehrsbetrieb_Potsdam_logo.svg.png",
|
||||
"color": 0x000000
|
||||
},
|
||||
"Vr": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/Vr_Logo.png/330px-Vr_Logo.png",
|
||||
"color": 0x00B451,
|
||||
"slogan": ["Aikaa yhteisellä matkalla"]
|
||||
},
|
||||
"Westbahn Management GmbH": {
|
||||
"logo": "https://corporate.westbahn.at/uploads/Logos/westbahn2025-logo-signet-small.png",
|
||||
"color": 0x1D4A83,
|
||||
"slogan": ["Immer ein guter Zug", "Da steig ich ein", "Jetzt aber Westbahn"]
|
||||
},
|
||||
"WestfalenBahn": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Westfalenbahn_logo.svg/330px-Westfalenbahn_logo.svg.png",
|
||||
"color": 0x48A1DD,
|
||||
"slogan": ["Weil's meine Region ist", "Hier bin ich daheim!"]
|
||||
},
|
||||
"db_allgemein": {
|
||||
"logo": "https://marketingportal.extranet.deutschebahn.com/resource/blob/13602522/c53f806b9df966e144010b276af72dd2/Bild_09-data.png",
|
||||
"color": 0xEC0016,
|
||||
"slogan": ["Senk ju vor träwelling wis Deutsche Bahn.", "Bitte beachten Sie die umgekehrte Wagenreihung.", "Zurückbleiben bitte!", "Alle reden vom Wetter. Wir nicht.", "Die Bahn macht mobil.", "Grün abgefahren"]
|
||||
},
|
||||
"db_bayern": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Bahnland_Bayern_Logo_2021.svg/500px-Bahnland_Bayern_Logo_2021.svg.png",
|
||||
"color": 0x0095DB
|
||||
},
|
||||
"db_bawü": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4f/Bwegt_Logo.svg/960px-Bwegt_Logo.svg.png",
|
||||
"color": 0xFFBF34,
|
||||
"slogan": ["Nett hier", "Aber waren Sie schon mal in Baden-Württemberg?", "Bwegt euch!"]
|
||||
},
|
||||
"Ostdeutsche Eisenbahn GmbH": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/ODEG-Logo_Neu.svg/960px-ODEG-Logo_Neu.svg.png",
|
||||
"color": 0x00745C
|
||||
"db_bayern": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Bahnland_Bayern_Logo_2021.svg/500px-Bahnland_Bayern_Logo_2021.svg.png",
|
||||
"color": 0x0095DB,
|
||||
"slogan": ["Zeit für dich", "Bringt dich vorwärts", "Wir bewegen das Bahnland Bayern", "Ey, i muas da ehrlich sogen i wünimma mit dir zaum sein, weil i a eif agrod gmerkt hob das i nix ernstes grod will und es liegt wirkli ned an dir oba es passt halt ned sound du müsstest holt a nü des abhoin wos du bei mir vergessen host"]
|
||||
},
|
||||
"NS": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Logo_NS.svg/960px-Logo_NS.svg.png",
|
||||
"color": 0X00337F,
|
||||
"slogan": ["Goed op weg", "Welkom in de trein van morgen", "Veilig, Vlug, Voordelig", "we haben een serious probleem", "Neuken in de keuken"]
|
||||
},
|
||||
"eurobahn": {
|
||||
"logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528405390260179146/Eurpnajm.png?ex=6a5e2df1&is=6a5cdc71&hm=d73fc3458f59f8942a5a0f51311308b12567b35b55b64aab0726bf6be92b3b0e&animated=true",
|
||||
"color": 0x005a9b,
|
||||
},
|
||||
"Arverio Bayern": {
|
||||
"logo": "https://cdn.discordapp.com/attachments/1383843132906537023/1528006572805062776/Arverio_Avi_Bayern_blau_RGB.png?ex=6a5cba83&is=6a5b6903&hm=5781ae6c372c92ab4f52409c6fc91e5014ac34ccf24db665ade052e8135bdde0&animated=true", # alternative nötig!
|
||||
"color": 0x0083BE
|
||||
},
|
||||
"Abellio Rail Mitteldeutschland GmbH": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Abellio_logo.svg/1920px-Abellio_logo.svg.png",
|
||||
"color": 0xD7002E
|
||||
},
|
||||
"SBB": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/23/SBB_logo_simplified.svg/960px-SBB_logo_simplified.svg.png",
|
||||
"color": 0xEB0000
|
||||
},
|
||||
"Nordbahn Eisenbahngesellschaft": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/b/b5/Logo_Nordbahn_NAH.SH_Blau_positiv_final.png",
|
||||
"color": 0x1A2848
|
||||
},
|
||||
"Erfurter Bahn GmbH": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ac/Erfurter_Bahn_logo.svg/960px-Erfurter_Bahn_logo.svg.png",
|
||||
"color": 0x009133
|
||||
},
|
||||
"S-Bahn Hannover (Transdev)": {
|
||||
"logo": "https://cdn.discordapp.com/attachments/1526274436523888732/1528470315234234520/sbahn.png?ex=6a5e6a68&is=6a5d18e8&hm=c9f630c6f0c646eafeccef9b4d591ce8fcf8856b2b45f14e72fbf25e32edb017&animated=true",
|
||||
"color": 0x1A4389
|
||||
},
|
||||
"Regionalverkehre Start Deutschland GmbH (Start Mitteldeutschland)": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/Logo_der_Regionalverkehre_Start_Deutschland.svg/960px-Logo_der_Regionalverkehre_Start_Deutschland.svg.png",
|
||||
"color": 0x61A731
|
||||
},
|
||||
"Westbahn Management GmbH": {
|
||||
"logo": "https://corporate.westbahn.at/uploads/Logos/westbahn2025-logo-signet-small.png",
|
||||
"color": 0x1D4A83
|
||||
},
|
||||
"European Sleeper": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/European_Sleeper_Logo.svg/960px-European_Sleeper_Logo.svg.png",
|
||||
"color": 0xEB4A27
|
||||
},
|
||||
"GoVolta": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/GoVolta_logo.svg/1280px-GoVolta_logo.svg.png",
|
||||
"color": 0x0B70F6
|
||||
},
|
||||
"SNCF": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/en/thumb/f/f4/Sncf-logo.svg/960px-Sncf-logo.svg.png",
|
||||
"color": 0x812B6D
|
||||
},
|
||||
"Agilis": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Agilis_Logo.svg/960px-Agilis_Logo.svg.png",
|
||||
"color": 0xCAE15B
|
||||
},
|
||||
"enno": {
|
||||
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Enno_logo.svg/960px-Enno_logo.svg.png",
|
||||
"color": 0x88216F
|
||||
},
|
||||
"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
|
||||
"fallback": {
|
||||
"logo": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQCVeC5E0mNNBKyQftQaFMzxIVkbDvEnSzWv07h_c8PdA&s=10",
|
||||
"color": 0xFFFFFF
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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ü"],
|
||||
"Bayerische Regiobahn": OPERATORS["db_bayern"],
|
||||
"DB Regio AG Bayern": OPERATORS["db_bayern"],
|
||||
"DB Fernverkehr AG": OPERATORS["db_allgemein"],
|
||||
"DB Regio AG": OPERATORS["db_allgemein"],
|
||||
"DB Regio AG NRW": OPERATORS["db_allgemein"],
|
||||
"DB Regio AG Südost": OPERATORS["db_allgemein"],
|
||||
"DB Regio AG Nordost": OPERATORS["db_allgemein"],
|
||||
@@ -125,7 +175,11 @@ OPERATOR_ALIASES = {
|
||||
"SBB GmbH": OPERATORS["SBB"],
|
||||
"Schweizerische Bundesbahnen SBB": OPERATORS["SBB"],
|
||||
"Dänische Staatsbahnen": OPERATORS["DSB"],
|
||||
"VR": OPERATORS["Vr"]
|
||||
"VR": OPERATORS["Vr"],
|
||||
"FlixBus-gb": OPERATORS["Flixbus"],
|
||||
"FlixBus-eu": OPERATORS["Flixbus"],
|
||||
"S-Bahn Berlin GmbH": OPERATORS["S-Bahn"],
|
||||
"DB Regio AG S-Bahn München": OPERATORS["S-Bahn"]
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
discord_status = [
|
||||
"Tschu-Tschu!",
|
||||
"Heute ca. 15 Minuten später",
|
||||
"Heute ohne Halt in Hamburg-Altona",
|
||||
"Störung an der Weiche",
|
||||
"Heute ohne Bordbistro",
|
||||
"SEV ist eingerichtet",
|
||||
"Gleiswechsel!",
|
||||
"Zug entgleist :(",
|
||||
"Die Bundespolizei ist informiert",
|
||||
"Dieser Zug endet hier",
|
||||
"Außerplanmäßiger Halt in Oberhausen Hbf",
|
||||
"Verspätung aus vorheriger Fahrt",
|
||||
"Der Zug hat keine Bremsen",
|
||||
"Heute ohne Klimatisierung",
|
||||
]
|
||||
@@ -4,4 +4,6 @@ from src.dc.embeds import build_info_embed
|
||||
def setup_commands(bot: discord.Bot):
|
||||
@bot.slash_command(description="Informationen über die aktuelle Fahrt")
|
||||
async def info(ctx):
|
||||
await ctx.respond(embed=build_info_embed())
|
||||
await ctx.defer()
|
||||
image = discord.File("src/data/assets/current_map.png", filename="ride.png")
|
||||
await ctx.respond(file=image, embed=build_info_embed())
|
||||
@@ -3,10 +3,14 @@ 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
|
||||
from src.config import config
|
||||
|
||||
def build_embed_footer(mode: str, slogans):
|
||||
footer_notice = f"Data provided by https://transitous.org • mode: {mode}"
|
||||
icon = "https://raw.githubusercontent.com/kaaninchen/Gleiswechsel/refs/heads/main/src/data/assets/transitous-logo.png"
|
||||
lang_embed = lang.embeds
|
||||
|
||||
def build_embed_footer(slogans):
|
||||
footer_notice = f"https://transitous.org/sources/"
|
||||
icon = "https://api.transitous.org/favicon.png"
|
||||
|
||||
if slogans is not None:
|
||||
footer_text = f"{random.choice(slogans)} • {footer_notice}"
|
||||
@@ -18,54 +22,57 @@ def build_embed_footer(mode: str, slogans):
|
||||
"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"])
|
||||
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=f"Abfahrt von {trip["station"]} um {departure}. Ankunft um {arrival}",
|
||||
title = trip["channel_name"],
|
||||
description=lang_embed.info.description(),
|
||||
color = metadata["color"]
|
||||
)
|
||||
|
||||
stops = trip["stops"]
|
||||
|
||||
next_stop = get_next_station(trip["stops"])
|
||||
print(f"next_stop: {next_stop}")
|
||||
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)
|
||||
via = format_via_list(stops, lang_embed.info.via_and())
|
||||
|
||||
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:
|
||||
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)
|
||||
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"])
|
||||
footer = build_embed_footer(metadata["slogans"])
|
||||
embed.set_footer(text=footer["text"], icon_url=footer["icon"])
|
||||
|
||||
embed.set_author(name=agency)
|
||||
embed.set_thumbnail(url=metadata["logo"])
|
||||
|
||||
if config.announcements.map:
|
||||
embed.set_image(url="attachment://ride.png")
|
||||
return embed
|
||||
|
||||
def build_announcement_embed(msg):
|
||||
from src.dc.handlers import trip
|
||||
agency = trip["agency"]
|
||||
metadata = get_operator_metadata(agency, trip["route_color"])
|
||||
metadata = get_operator_metadata(agency, trip["route_color"], trip["mode"])
|
||||
|
||||
embed = discord.Embed(
|
||||
title = "Informationen zu ihrer Fahrt",
|
||||
title = lang_embed.announcement.title(),
|
||||
description=msg,
|
||||
color=metadata["color"]
|
||||
)
|
||||
@@ -73,7 +80,7 @@ def build_announcement_embed(msg):
|
||||
embed.set_author(name=agency)
|
||||
embed.set_thumbnail(url=metadata["logo"])
|
||||
|
||||
footer = build_embed_footer(trip["mode"], metadata.get("slogan"))
|
||||
footer = build_embed_footer(metadata.get("slogan"))
|
||||
embed.set_footer(text=footer["text"], icon_url=footer["icon"])
|
||||
|
||||
return embed
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import discord
|
||||
import asyncio
|
||||
import aiohttp
|
||||
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, generate_static_map
|
||||
from src.config import config
|
||||
from src.lang.locales import lang
|
||||
|
||||
_scheduled_task: asyncio.Task | None = None
|
||||
_next_stop_task: asyncio.Task | None = None
|
||||
channel_lang = lang.channel
|
||||
|
||||
async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = False):
|
||||
global trip, _scheduled_task
|
||||
@@ -14,97 +18,80 @@ async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = Fals
|
||||
_scheduled_task.cancel()
|
||||
|
||||
attempt = 0
|
||||
max_attempt = 15
|
||||
max_attempt = 30
|
||||
trip = choose_connection()
|
||||
while trip is None and attempt < max_attempt:
|
||||
attempt += 1
|
||||
logger(f"Attempt {attempt}: Failed to select route, retrying...", "error")
|
||||
trip = choose_connection()
|
||||
|
||||
if trip is None:
|
||||
logger(f"Failed to select route after {max_attempt} attempts", "fatal")
|
||||
return False
|
||||
|
||||
if config.announcements.map:
|
||||
generate_static_map(trip["stops"], trip["mode"], trip["agency"], trip["route_color"])
|
||||
|
||||
arrival = trip["arrival"]
|
||||
long_name = trip["long_name"]
|
||||
channel_name = trip["channel_name"]
|
||||
|
||||
mode = trip["mode"]
|
||||
|
||||
print("-----------------")
|
||||
logger(f"Umstieg: {long_name}; Ankunft: {arrival} Uhr")
|
||||
logger(f"Betreiber: {trip["agency"]}, Typ: {mode}")
|
||||
logger(f"Versuche Namen zu ändern, wenn nichts passiert bin ich im cooldown... (warte bis zu 10min!)")
|
||||
logger(f"Transfer: {channel_name}; Arrival: {arrival}")
|
||||
logger(f"Agency: {trip["agency"]}, mode: {mode}")
|
||||
logger(f"Trying to change the channels name. If nothing happens, then the bot is in cooldown... (automatically resolves after up to 10min)")
|
||||
|
||||
formatting = channel_formatting(mode)
|
||||
await voice_channel.edit(name=f"{formatting}{long_name}")
|
||||
await voice_channel.set_status(f"Ankunft um {arrival}")
|
||||
await voice_channel.edit(name=f"{formatting}{channel_name}")
|
||||
await voice_channel.set_status(None)
|
||||
start_next_stop_updates(voice_channel)
|
||||
|
||||
logger(f"Name geändert!")
|
||||
logger(f"Updated channel name!")
|
||||
|
||||
await announcer("umstieg", voice_channel)
|
||||
await bot.change_presence(activity=discord.Game(name=lang.rich_presence.arrival()))
|
||||
await announcer("transfer", voice_channel)
|
||||
|
||||
_scheduled_task = asyncio.create_task(_schedule_next_transfer(bot, arrival, 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)
|
||||
_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
|
||||
announcements_enabled = config.announcements.text_announcements
|
||||
|
||||
if announcements_enabled:
|
||||
image = None
|
||||
if len(voice_channel.members) > 0:
|
||||
match announcement:
|
||||
case "ende":
|
||||
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:
|
||||
await voice_announcer(destination, voice_channel)
|
||||
case "umstieg":
|
||||
case "end_of_connection":
|
||||
embed = build_announcement_embed(lang.embeds.announcement.end_of_connection.message())
|
||||
case "transfer":
|
||||
embed = build_info_embed()
|
||||
image = discord.File("src/data/assets/current_map.png", filename="ride.png")
|
||||
case _:
|
||||
logger(f"Unbekanntes Announcements: {announcement}")
|
||||
logger(f"Unknown announcement: {announcement}")
|
||||
embed = None
|
||||
|
||||
if embed:
|
||||
await voice_channel.send(embed=embed)
|
||||
if image and config.announcements.map:
|
||||
await voice_channel.send(file=image, embed=embed)
|
||||
else:
|
||||
logger(f"Announcement {announcement} wird geskipped, keiner da")
|
||||
return
|
||||
await voice_channel.send(embed=embed)
|
||||
|
||||
async def voice_announcer(destination: str, voice_channel: discord.VoiceChannel):
|
||||
sound_path = get_sound_path(destination=destination)
|
||||
async def voice_announcer(station: str, voice_channel: discord.VoiceChannel) -> bool:
|
||||
sound_path = get_sound_path(station=station)
|
||||
if sound_path is None:
|
||||
return
|
||||
return False
|
||||
|
||||
logger(f"VC wird betreten, spiele {sound_path}")
|
||||
vc = await voice_channel.connect(timeout=15, reconnect=True)
|
||||
if voice_channel.guild.voice_client:
|
||||
logger(f"Already in vc, skipping this announcement to be safe")
|
||||
return False
|
||||
|
||||
logger(f"Joining vc, playing {sound_path}")
|
||||
|
||||
connect_task = asyncio.create_task(voice_channel.connect(timeout=15, reconnect=True))
|
||||
audio_source = discord.FFmpegPCMAudio(sound_path)
|
||||
|
||||
vc = await connect_task
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
if not vc.is_playing():
|
||||
@@ -112,6 +99,75 @@ async def voice_announcer(destination: str, voice_channel: discord.VoiceChannel)
|
||||
if error:
|
||||
logger(f"Player error: {error}", "error")
|
||||
loop.create_task(vc.disconnect())
|
||||
logger("VC wird verlassen")
|
||||
logger("Leaving vc")
|
||||
|
||||
vc.play(audio_source, after=after_playing)
|
||||
return True
|
||||
|
||||
async def _schedule_next_transfer(bot: discord.Bot, arrival_dt: datetime, voice_channel: discord.VoiceChannel, destination: str):
|
||||
now = datetime.now(LOCAL_TZ)
|
||||
|
||||
wait_seconds = (arrival_dt - now).total_seconds()
|
||||
announcement_countdown = random.randrange(180, 300)
|
||||
|
||||
if wait_seconds > 0:
|
||||
remaining = str(timedelta(seconds=wait_seconds))
|
||||
logger(f"Next transfer in {remaining.split('.')[0]} ({arrival_dt.strftime('%H:%M')})")
|
||||
|
||||
if wait_seconds > announcement_countdown:
|
||||
wait_until_end_announcement = wait_seconds - announcement_countdown
|
||||
await asyncio.sleep(wait_until_end_announcement)
|
||||
await announcer("end_of_connection", voice_channel, destination)
|
||||
await asyncio.sleep(announcement_countdown)
|
||||
else:
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
logger("Train arrived, searching for a new connection....")
|
||||
await rename_vc(bot, voice_channel, from_scheduler=True)
|
||||
|
||||
async def _update_next_loop(voice_channel: discord.VoiceChannel):
|
||||
global trip
|
||||
try:
|
||||
if trip is None:
|
||||
return
|
||||
|
||||
now = datetime.now(LOCAL_TZ)
|
||||
departure_dt = trip["departure_dt"]
|
||||
|
||||
if departure_dt > now:
|
||||
wait_seconds = (departure_dt - now).total_seconds() + 10
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
while True:
|
||||
next_stop = get_next_station(trip["stops"], trip["from"])
|
||||
|
||||
if next_stop is None:
|
||||
return
|
||||
|
||||
next_stop_str = next_stop["name"]
|
||||
|
||||
status_text = f"{lang.embeds.info.next_stop()}: {next_stop_str}"
|
||||
|
||||
try:
|
||||
await voice_channel.set_status(status_text, reason="Next stop status")
|
||||
except aiohttp.ClientConnectorDNSError as e:
|
||||
logger(F"Failed to set the channel status (network error?): {e}", "error")
|
||||
|
||||
if config.announcements.voice_announcements:
|
||||
if len(voice_channel.members) > 0:
|
||||
await voice_announcer(next_stop_str, voice_channel)
|
||||
|
||||
wait_seconds = (next_stop["arrival"] - datetime.now(LOCAL_TZ)).total_seconds()
|
||||
if wait_seconds > 0:
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
def start_next_stop_updates(voice_channel: discord.VoiceChannel):
|
||||
global _next_stop_task
|
||||
|
||||
if _next_stop_task is not None and not _next_stop_task.done():
|
||||
_next_stop_task.cancel()
|
||||
|
||||
_next_stop_task = asyncio.create_task(_update_next_loop(voice_channel))
|
||||
@@ -6,12 +6,12 @@ def validate_channel(bot: discord.bot, server_id: int, channel_id: int):
|
||||
guild = bot.get_guild(server_id)
|
||||
|
||||
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
|
||||
|
||||
channel = guild.get_channel(channel_id)
|
||||
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 channel
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# generated by datamodel-codegen:
|
||||
# filename: en.yaml
|
||||
# timestamp: 2026-08-23T14:08:25+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
|
||||
short_name: 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:
|
||||
info: Info
|
||||
announcement: Announcement
|
||||
|
||||
|
||||
@dataclass
|
||||
class RichPresence:
|
||||
arrival: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Model:
|
||||
channel: Channel
|
||||
embeds: Embeds
|
||||
rich_presence: RichPresence
|
||||
@@ -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)
|
||||
@@ -1,8 +1,8 @@
|
||||
import os
|
||||
import importlib
|
||||
import random
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import staticmaps
|
||||
from src.config import config
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -10,7 +10,7 @@ import src.data.operators as operators
|
||||
from src.data.emojis import emoji_list
|
||||
|
||||
_operator_mtime = None
|
||||
|
||||
LOCAL_TZ = ZoneInfo(config.connections.timezone)
|
||||
|
||||
def logger(msg, log_type="info") -> str:
|
||||
status = log_type.upper()
|
||||
@@ -27,52 +27,50 @@ def choose_connection() -> dict | None:
|
||||
|
||||
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)
|
||||
start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
|
||||
start_local = start_dt.astimezone(LOCAL_TZ).strftime('%H:%M')
|
||||
|
||||
end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
|
||||
if end_dt < now:
|
||||
logger(f"Verbindung liegt bereits in der Vergangenheit: {start_dt}", "error")
|
||||
logger(f"Connection is from the past: {start_local}", "error")
|
||||
return False
|
||||
|
||||
max_wait_time = config.connections.max_wait_time
|
||||
start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
|
||||
if 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_local} (max_wait_time: {max_wait_time}h), retrying...", "error")
|
||||
return False
|
||||
|
||||
station_departure_dt = datetime.fromisoformat(station_departure.replace("Z", "+00:00"))
|
||||
trip_duration = (end_dt - station_departure_dt).total_seconds()
|
||||
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"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), retrying...", "error")
|
||||
return False
|
||||
|
||||
max_duration = config.connections.max_duration
|
||||
if max_duration:
|
||||
max_duration_seconds = max_duration * 60
|
||||
if max_duration_seconds < trip_duration:
|
||||
logger(f"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), retrying...", "error")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def convert_iso_string(isostring) -> str:
|
||||
timezone = config.connections.timezone
|
||||
dt = datetime.fromisoformat(isostring.replace('Z', '+00:00'))
|
||||
dt = dt.astimezone(ZoneInfo(timezone))
|
||||
|
||||
if dt.second >= 30:
|
||||
dt += timedelta(minutes=1)
|
||||
return dt.strftime('%H:%M')
|
||||
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.formatting:
|
||||
if config.discord.emojis:
|
||||
emoji = emoji_list.get(mode)
|
||||
if emoji is None:
|
||||
emoji = emoji_list.get("Fallback")
|
||||
@@ -80,7 +78,7 @@ def channel_formatting(mode: str) -> str:
|
||||
return f"{emoji}{formatting}"
|
||||
|
||||
def get_train_name(train_name: str, mode: str) -> str:
|
||||
if mode == "BUS" or mode == "TRAM":
|
||||
if train_name.isdigit():
|
||||
train = f"{mode.capitalize()} {train_name}"
|
||||
elif "(" in train_name:
|
||||
train = train_name.split(" (")[0]
|
||||
@@ -102,10 +100,8 @@ def _reload_operators_if_changed():
|
||||
if current_mtime != _operator_mtime:
|
||||
importlib.reload(operators)
|
||||
_operator_mtime = current_mtime
|
||||
logger("operators.py wurde automatisch neu geladen (Änderungen erkannt)")
|
||||
|
||||
|
||||
def get_operator_metadata(agency: str, route_color: str) -> dict:
|
||||
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"]
|
||||
@@ -120,8 +116,6 @@ def get_operator_metadata(agency: str, route_color: str) -> dict:
|
||||
color = int(route_color, 16)
|
||||
except ValueError:
|
||||
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:
|
||||
color = operators.OPERATORS["fallback"]["color"]
|
||||
return {
|
||||
@@ -130,66 +124,71 @@ def get_operator_metadata(agency: str, route_color: str) -> dict:
|
||||
"slogans": slogans
|
||||
}
|
||||
|
||||
def get_sound_path(destination) -> str | None:
|
||||
voice_stations = config.announcements.voice[0].stations
|
||||
def get_sound_path(station: str) -> str | None:
|
||||
announcement_dir = Path("src/data/announcements")
|
||||
|
||||
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"Konnte Datei {sound_path} nicht finden", "error")
|
||||
return None
|
||||
|
||||
return sound_path
|
||||
|
||||
def get_next_station(stops: dict) -> dict | None:
|
||||
now = datetime.now()
|
||||
today = datetime.today()
|
||||
day_offset = 0
|
||||
previous_time = None
|
||||
|
||||
for name, arrival_str in stops.items():
|
||||
arrival_time = datetime.strptime(arrival_str, "%H:%M").time()
|
||||
|
||||
if previous_time is not None and arrival_time < previous_time:
|
||||
day_offset += 1
|
||||
|
||||
arrival_dt = datetime.combine(today + timedelta(days=day_offset), arrival_time)
|
||||
previous_time = arrival_time
|
||||
for file in announcement_dir.iterdir():
|
||||
if file.is_file():
|
||||
if station.lower() in file.stem.lower():
|
||||
sound_file = file.resolve()
|
||||
return sound_file
|
||||
|
||||
def get_next_station(stops: dict, train_from: str) -> dict | None:
|
||||
now = datetime.now(LOCAL_TZ)
|
||||
for name, info in stops.items():
|
||||
arrival_dt = info["arrival"]
|
||||
if arrival_dt >= now:
|
||||
return {"name": name, "arrival": arrival_str}
|
||||
if name == train_from:
|
||||
return None
|
||||
else:
|
||||
return {
|
||||
"name": name,
|
||||
"arrival": arrival_dt
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def format_via_list(stops: dict) -> str:
|
||||
def format_via_list(stops: dict, via_and: str) -> str:
|
||||
if len(stops) > 2:
|
||||
count = min(3, len(stops))
|
||||
random_stops = random.sample(list(stops), k=count)
|
||||
via = f"{', '.join(random_stops[:-1])} und {random_stops[-1]}"
|
||||
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, stop_arrival in stops.items():
|
||||
for name, info in stops.items():
|
||||
if name == next_stop:
|
||||
line = f"• __{name} ({stop_arrival} Uhr__)"
|
||||
stop_arrival = info["arrival"]
|
||||
line = f"• __{name}__ ({stop_arrival.strftime("%H:%M")})"
|
||||
else:
|
||||
line = f"• {name} ({stop_arrival} Uhr)"
|
||||
stop_arrival = info["arrival"]
|
||||
line = f"• {name} ({stop_arrival.strftime("%H:%M")})"
|
||||
|
||||
if field_length + len(line) + 1 > 1024:
|
||||
route_page_name = "Route" if part == 1 else "Route (Fortsetzung)"
|
||||
@@ -204,3 +203,72 @@ def format_stop_list(stops: dict, next_stop: str | None) -> list[tuple[str, str]
|
||||
fields.append((route_page_name, "\n".join(field_lines)))
|
||||
|
||||
return fields
|
||||
|
||||
def generate_static_map(stops: dict, mode: str, operator: str, route_color: str):
|
||||
logger("Generating a new route map..")
|
||||
operator_metadata = get_operator_metadata(operator, route_color, mode)
|
||||
operator_color = operator_metadata["color"]
|
||||
if operator_color == 0xFFFFFF:
|
||||
stop_color = staticmaps.Color(105, 105, 105, 255)
|
||||
else:
|
||||
stop_color = staticmaps.parse_color(f"#{operator_color:06x}")
|
||||
|
||||
white = staticmaps.Color(255, 255, 255, 255)
|
||||
|
||||
context = staticmaps.Context()
|
||||
|
||||
context.set_tile_provider(staticmaps.tile_provider_OSM)
|
||||
|
||||
stop_coords = [
|
||||
staticmaps.create_latlng(
|
||||
stop["lat"],
|
||||
stop["lon"],
|
||||
)
|
||||
for stop in stops.values()
|
||||
]
|
||||
|
||||
context.add_object(
|
||||
staticmaps.Line(
|
||||
stop_coords,
|
||||
color=white,
|
||||
width=15,
|
||||
)
|
||||
)
|
||||
|
||||
context.add_object(
|
||||
staticmaps.Line(
|
||||
stop_coords,
|
||||
color=stop_color,
|
||||
width=8,
|
||||
)
|
||||
)
|
||||
|
||||
last_index = len(stop_coords) - 1
|
||||
for i, point in enumerate(stop_coords):
|
||||
|
||||
if i == 0 or i == last_index:
|
||||
context.add_object(
|
||||
staticmaps.Circle(
|
||||
point,
|
||||
radius_km=0.1,
|
||||
fill_color=white,
|
||||
color=stop_color,
|
||||
width=12
|
||||
)
|
||||
)
|
||||
if i == last_index:
|
||||
context.add_object(
|
||||
staticmaps.Marker(
|
||||
point,
|
||||
color=stop_color,
|
||||
size=12
|
||||
)
|
||||
)
|
||||
try:
|
||||
image = context.render_cairo(400, 300)
|
||||
image.write_to_png("src/data/assets/current_map.png")
|
||||
except RuntimeError:
|
||||
logger(f"You don't have cairo installed! Because of that I can only give you a low-res png of the route...")
|
||||
image = context.render_pillow(400, 300)
|
||||
image.save("src/data/assets/current_map.png")
|
||||
logger("Map generated!")
|
||||
|
||||