Compare commits

..
48 Commits
Author SHA1 Message Date
Kaaninchen f65aa3cfa0 fix grammatical error in NS slogans (how embarassing)... 2026-08-28 19:53:49 +02:00
Kaaninchen e9708d435e fix example config 2026-08-28 19:42:32 +02:00
Kaaninchen d50cf0277b readme 2026-08-28 15:51:41 +02:00
Kaaninchen dde0c443de map improvements 2026-08-27 13:10:30 +02:00
Kaaninchen 93b9712394 up max_attempt to 30 2026-08-24 07:55:06 +02:00
Kaaninchen 21a279839d update favicon 2026-08-23 17:57:01 +02:00
Kaaninchen 1c006731a4 feat: priority list 2026-08-23 17:21:22 +02:00
Kaaninchen db43ee4279 forgot to include de.yaml 2026-08-23 16:10:11 +02:00
Kaaninchen dc2f9dade0 move footer_notice from lang files to source code 2026-08-23 16:09:52 +02:00
Kaaninchen 10b6ee560e README: rename python run file to python file 2026-08-23 13:39:48 +02:00
Kaaninchen 53ded15209 fix: actually intercept voice_channel status error 2026-08-22 23:38:21 +02:00
Kaaninchen c24749383b fix: make the bot API conform
i am SO sorry transitous team I only saw the notice now
2026-08-21 23:48:45 +02:00
Kaaninchen 5609a3c425 fix: intercept voice_channel status error 2026-08-21 18:40:44 +02:00
Kaaninchen 8dfa092229 fix: no attribute strftime 2026-08-20 22:29:46 +02:00
Kaaninchen 62d5569bcc fix: return raw departure if start name == from_station (fixes min_duration) 2026-08-20 22:26:35 +02:00
Kaaninchen f716b770dc fix: config.json.example (2) 2026-08-20 21:41:51 +02:00
Kaaninchen 9e7c645724 fix: config.json.example 2026-08-20 21:41:24 +02:00
Kaaninchen 8c4e78c493 operators.py: S-Bahn Berlin GmbH 2026-08-20 21:40:09 +02:00
Kaaninchen 7e47874f78 remove circles on layovers in the embed map
they were ugly and also took forever to generate
2026-08-20 17:53:22 +02:00
Kaaninchen 1069879680 operators.py: Flixbus-eu 2026-08-20 17:49:19 +02:00
Kaaninchen a4ae134ca0 fix: end_of_connection announcement 2026-08-20 17:46:47 +02:00
Kaaninchen 054ab7fc42 fix: shorten channel name if it's too long for discords limit 2026-08-20 13:22:14 +02:00
Kaaninchen 999fe141b9 operators.py: Aktivbus Flensburg GmbH 2026-08-20 10:35:19 +02:00
Kaaninchen 595e7160df fix next_status_loop 2026-08-19 22:48:19 +02:00
Kaaninchen ead53f2f75 operators.py: Verkehrsbetrieb Potsdam GmbH 2026-08-19 22:44:33 +02:00
Kaaninchen ed7ae614cc operators.py: Erfurter Bahn 2026-08-19 22:17:54 +02:00
Kaaninchen 714841df22 feat: map of route in /info embed 2026-08-19 22:16:56 +02:00
Kaaninchen cd2d968545 operators.py: slogans 2026-08-19 11:48:29 +02:00
Kaaninchen 2e62355db9 operators.py: Westfalenbahn 2026-08-18 22:34:17 +02:00
Kaaninchen e6013a437f arrival as discord rpc 2026-08-18 22:17:45 +02:00
Kaaninchen d46a04af0c some logging improvements 2026-08-18 20:44:54 +02:00
Kaaninchen 6006a561b2 only run voice_announcements if there's someone in the vc 2026-08-18 20:33:31 +02:00
Kaaninchen 881d2e865b update config 2026-08-18 20:25:39 +02:00
Kaaninchen 66ec499c27 rewrite audio announcements feature to be simpler to setup 2026-08-18 20:20:30 +02:00
Kaaninchen 7a3902b7d3 add exact match notice on stations helper function 2026-08-18 18:47:51 +02:00
Kaaninchen bd506ff9da remove 'uhr' 2026-08-18 14:53:55 +02:00
Kaaninchen 59b2ccd64a Update example output in stations helper 2026-08-18 14:48:49 +02:00
martinandGitHub 768da0ee4d Merge pull request #1 from kaaninchen/transitous_rewrite
The bot has been (almost) completely rewritten to use the https://transitous.org/ API. Take a look at the README for all the changes!
2026-08-18 14:41:00 +02:00
Kaaninchen 637a1f19a0 transitous rewrite: set default min_duration to 10 2026-08-18 14:36:59 +02:00
Kaaninchen 3dcf1e8626 transitous rewrite: only show next_stop if its actually stop and not the beginn of the route 2026-08-18 14:27:47 +02:00
Kaaninchen 729f350924 transitous rewrite: add IDs 2026-08-18 14:14:10 +02:00
Kaaninchen a6342ca039 transitous rewrite: fix next_stop 2026-08-18 13:39:00 +02:00
Kaaninchen 260ab043f4 transitous rewrite: fix image in README 2026-08-18 13:27:01 +02:00
Kaaninchen 9f566a5bf3 transitous rewrite: change json to sh in README 2026-08-18 13:26:35 +02:00
Kaaninchen 000492f320 transitous rewrite: update example config in README 2026-08-18 13:25:47 +02:00
Kaaninchen 4a73ec6ef2 transitous rewrite: update README 2026-08-18 13:24:08 +02:00
Kaaninchen 5886c747fe transitous rewrite: voice_announcements on stops 2026-08-18 11:41:15 +02:00
Kaaninchen b608656bc7 transitous rewrite: forgot to commit them 2026-08-18 00:24:02 +02:00
25 changed files with 691 additions and 295 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+1
View File
@@ -1,6 +1,7 @@
venv/
__pycache__/
src/data/announcements/*
src/data/assets/current_map.png
!src/data/announcements/.gitkeep
config.json
stations.json
+218
View File
@@ -1 +1,219 @@
# Gleiswechsel
Gleiswechsel [ˈɡlaɪsvɛksəl] (german for "platform change") is a selfhostable discord bot which regularly changes the name of a voice chat to a real public transport connection. The channel will keep the name until the connection arrived at its last stop, after that it will automatically select a new one.
![Example Channel](.github/preview_kanal.png)
## 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
![Example info](.github/preview_info.png)
## Setup
Follow [this guide](https://guide.pycord.dev/getting-started/creating-your-first-bot) to create your discord bot up until you have a token.
Make sure that you have [Python](https://www.python.org/) installed!
Download the repo (either with git or by clicking the big "Code" button at the start on this page, downloading the zip and extracting it) and run:
```sh
# 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
```
Next, you should configure the bot to your liking
## 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"
]
```
The bot will also warn you if it couldn't find a station with the exact name as your input:
```
22:44:46: INFO: No station associated as 'Amsterdam', choosing random from similar named stations
```
but you can safely ignore this warning if you're fine with it.
If not, I've created a tool which would help you to get the exact station name. You can use the tool by running
```sh
$ python main.py stations
```
Example output:
```json
"stations": {
"Amsterdam": [
"Amsterdam Zuid",
"Amsterdam RAI",
"Amsterdam Amstel",
"Amsterdam, Gein",
"Amsterdam Centraal",
"Amsterdam, Noord",
"Amsterdam, Rokin",
"Amsterdam Lelylaan",
"Amsterdam Sloterdijk"
],
"Berlin": [
"Berlin Hbf",
"Berlin ZOB",
"Berlin Ostbf",
"Berlin-Spandau",
"Berlin Südkreuz",
"U Rudow (Berlin)",
"S Buch (Berlin)",
"U Hönow (Berlin)"
]
}
```
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.
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.
This is especially useful if you want to add a station whose name isn't unique and also used by other stations. The bot would falsely use the first station with the same name and consider it an exact match, even if you wanted a different one. This won't happen with the ID, as every ID is uniquely assigned to only one station.
##### priority
You can define priorities of transport modes while selecting a connection. Leave empty to disable
The list should be descending, with "1" as the most important transport mode. For example, let's say, the bot should always choose Trains and other longer distance public transport modes. If it can't find any, then it should resort to Metros/Subways/Suburbans. If these are also missing at that station, then it should just use Busses/Trams:
```json
"priority": {
"HIGHSPEED_RAIL": 1,
"LONG_DISTANCE": 1,
"NIGHT_RAIL": 1,
"REGIONAL_RAIL": 1,
"COACH": 1,
"FERRY": 1,
"METRO": 2,
"SUBURBAN": 2,
"SUBWAY": 2,
"BUS": 3,
"TRAM": 3
}
```
A transport mode that isn't in the priority list would immediately get assigned the value of "99", making it near impossible to get that connection. Except when there are no other connections available, of course.
You can find the MODE names either in the console log or the stations.json from [helper tool](#stations)
##### blacklist
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
```
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
- `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.
- `max_duration` is the maximun duration of the connection in minutes. Set to `null` to disable
- `max_wait_time` is the maximun wait time for a connection in hours. Don't set that one too low, as the bot may have some issues finding a suitable connection at night, or at stations that aren't frequently used. Set to `null` to disable
- `timezone` is timezone in the IANA timezone format. You can [look it up here](https://www.addevent.com/c/documentation/tools/time-zone-lookup)
##### announcements
##### `text_announcements:`
The bot can send a text announcement in the voice chat at the start/end of a trip.
At the end of a trip, it would send this embed:
![end_of_trip](.github/end_of_trip_announcement.png)
It will also send the `/info` embed at the start of a new connection with informations about your new trip.
To reduce spam, the bot will only send announcements if someone is in the voice chat
##### `voice_announcements:`
The bot can join the voice chat, play an audio file, and disconnect from the voice chat, at various points of your trip. You have to have [FFmpeg](https://www.ffmpeg.org/) installed for this to work.
Place the audio file of your desired station in [src/data/announcements](src/data/announcements/) with the EXACT name of the station. The bot will automatically check if an audio file with the stations name exists, and if it does, play it.
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
+14 -15
View File
@@ -1,8 +1,8 @@
{
"discord": {
"token": "",
"server": ,
"vc": ,
"server": "",
"vc": "",
"lang": "de",
"formatting": "┇",
"emojis": true
@@ -13,24 +13,23 @@
"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
View File
@@ -24,6 +24,7 @@ async def on_ready():
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)
+1
View File
@@ -13,6 +13,7 @@ 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
+87 -60
View File
@@ -8,9 +8,9 @@ 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
long_name_lang = lang.channel.long_name
user_agent = config.http.user_agent
headers = {
@@ -20,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:
@@ -53,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:
@@ -63,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:
@@ -71,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))
@@ -91,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:
@@ -113,7 +134,6 @@ def get_random_stop_id() -> str | None:
if stop_name == assigned_station:
stop_ids.clear()
logger(f"Exact match found! Using {stop_name}")
stop_ids[stop_id] = stop_name
break
@@ -122,11 +142,12 @@ def get_random_stop_id() -> str | None:
return None
stop_ids_list = list(stop_ids.keys())
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")
chosen_stop_id, chosen_station = random.choice(list(stop_ids.items()))
logger(f"Assigned Station: {chosen_station}")
logger(f"Selected station: {stop_ids[chosen_stop_id]}")
return chosen_stop_id
def get_random_connection(stop_id: str) -> str | None:
@@ -134,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)
@@ -155,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:
@@ -218,18 +225,25 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
logger(f"Bot doesn't know what to do with round trips, I'll have to implement this some day", "error")
return None # TODO actually handel this
all_stop_names = [train_from] + [stop["name"] for stop in legs["intermediateStops"]] + [goes_to]
if len(all_stop_names) != len(set(all_stop_names)):
logger("Route reuses station names! Skipping, to be safe...", "error")
return None # TODO this too
arrival_dt = parse_iso(end_time)
departure_dt = parse_iso(start_time)
train_name = get_train_name(display_name, mode)
if train_from == from_station:
long_name = long_name_lang.train_from()
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 = long_name_lang.train_via()
if train_from == from_station:
channel_name = lang.channel.long_name.train_from()
else:
channel_name = lang.channel.long_name.train_via()
trip_details = {
"long_name": long_name,
"short_name": display_name,
"channel_name": channel_name,
"station": from_station,
"from": train_from,
"to": goes_to,
@@ -246,7 +260,9 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
train_from_importance = legs["from"]["importance"]
trip_details["stops"][train_from] = {
"arrival": departure_dt,
"importance": train_from_importance
"importance": train_from_importance,
"lat": legs["from"]["lat"],
"lon": legs["from"]["lon"]
}
departure_time_iso = start_time
@@ -256,13 +272,24 @@ def get_trip_details(random_connection: dict | None) -> dict | None:
stop_importance = stop["importance"]
stop_details = {
"arrival": stop_arrival_dt,
"importance": stop_importance
"importance": stop_importance,
"lat": stop["lat"],
"lon": stop["lon"]
}
trip_details["stops"][stop["name"]] = stop_details
if stop.get("name") == from_station:
departure_time_iso = stop["departure"]
trip_details["departure"] = parse_iso(departure_time_iso).strftime("%H:%M")
train_to_importance = legs["to"]["importance"]
trip_details["stops"][goes_to] = {
"arrival": arrival_dt,
"importance": train_to_importance,
"lat": legs["to"]["lat"],
"lon": legs["to"]["lon"]
}
valid = validate_connection(start_time, end_time, departure_time_iso)
if not valid:
return None
+8 -13
View File
@@ -1,5 +1,5 @@
import json
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Optional
@dataclass
@@ -14,21 +14,19 @@ class DiscordConfig:
@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"]),
)
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,2 +0,0 @@
SPDX-FileCopyrightText: 2024 Mathis Brüchert <[email protected]>
SPDX-License-Identifier: CC-BY-SA-4.0
+2 -1
View File
@@ -7,5 +7,6 @@ emoji_list = {
"HIGHSPEED_RAIL": "🚅",
"LONG_DISTANCE": "🚅",
"METRO": "🚇",
"SUBWAY": "🚇"
"SUBWAY": "🚇",
"FERRY": "🚢"
}
+3 -2
View File
@@ -2,9 +2,8 @@ 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:
footer:
notice: "Daten großzügig bereitgestellt von https://transitous.org"
info:
description: "Abfahrt von {station} um {departure}. Ankunft um {arrival}"
via: "Über"
@@ -14,3 +13,5 @@ embeds:
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"
+3 -2
View File
@@ -2,9 +2,8 @@ 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:
footer:
notice: "Data provided by https://transitous.org"
info:
description: "Departure from {station} at {departure}. Arrival by {arrival}"
via: "via"
@@ -14,3 +13,5 @@ embeds:
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"
+148 -105
View File
@@ -1,124 +1,162 @@
''''
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
},
"GVB": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/BSicon_LOGO_GVB.svg/960px-BSicon_LOGO_GVB.svg.png",
"color": 0x2B62AF
},
"Berliner Verkehrsbetriebe": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bf/BVG_Logo_07.2021.svg/960px-BVG_Logo_07.2021.svg.png",
"color": 0xEFD13C
"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ü"],
@@ -127,6 +165,7 @@ OPERATOR_ALIASES = {
"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"],
@@ -136,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"]
}
-16
View File
@@ -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",
]
+3 -1
View File
@@ -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())
+9 -6
View File
@@ -4,12 +4,13 @@ import random
from src.utils import get_operator_metadata, get_next_station, format_via_list, format_stop_list
from src.dc.helpers import format_timestamp_to_dc
from src.lang.locales import lang
from src.config import config
lang_embed = lang.embeds
def build_embed_footer(mode: str, slogans):
footer_notice = f"{lang.embeds.footer.notice()} • mode: {mode}"
icon = "https://raw.githubusercontent.com/kaaninchen/Gleiswechsel/refs/heads/main/src/data/assets/transitous-logo.png"
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}"
@@ -32,7 +33,7 @@ def build_info_embed() -> discord.Embed:
arrival = format_timestamp_to_dc(trip["arrival"])
embed = discord.Embed(
title = trip["long_name"],
title = trip["channel_name"],
description=lang_embed.info.description(),
color = metadata["color"]
)
@@ -55,12 +56,14 @@ def build_info_embed() -> discord.Embed:
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):
@@ -77,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
+79 -17
View File
@@ -1,13 +1,15 @@
import discord
import asyncio
import aiohttp
import random
from datetime import datetime, timedelta
from src.utils import logger, channel_formatting, choose_connection, get_sound_path, LOCAL_TZ, get_next_station
from src.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):
@@ -16,7 +18,7 @@ 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
@@ -26,57 +28,70 @@ async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = Fals
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"Transfer: {long_name}; Arrival: {arrival}")
logger(f"Transfer: {channel_name}; Arrival: {arrival}")
logger(f"Agency: {trip["agency"]}, mode: {mode}")
logger(f"Trying to change channel name. Discord put the bot into a cooldown if nothing happens... (automatically resolves after up to 10min)")
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(channel_lang.status())
await voice_channel.edit(name=f"{formatting}{channel_name}")
await voice_channel.set_status(None)
start_next_stop_updates(voice_channel)
logger(f"Updated channel name!")
await bot.change_presence(activity=discord.Game(name=lang.rich_presence.arrival()))
await announcer("transfer", voice_channel)
_scheduled_task = asyncio.create_task(_schedule_next_transfer(bot, trip["arrival_dt"], voice_channel, trip["to"]))
async def announcer(announcement: str, voice_channel: discord.VoiceChannel, destination = None):
from src.dc.embeds import build_info_embed, build_announcement_embed
announcements_enabled = config.announcements.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 "end_of_connection":
if voice_announcement_enabled:
announcement_status = await voice_announcer(destination, voice_channel)
if announcement_status:
return
embed = build_announcement_embed(lang.embeds.announcement.end_of_connection.message())
case "transfer":
embed = build_info_embed()
image = discord.File("src/data/assets/current_map.png", filename="ride.png")
case _:
logger(f"Unknown announcement: {announcement}")
embed = None
if embed:
if image and config.announcements.map:
await voice_channel.send(file=image, embed=embed)
else:
await voice_channel.send(embed=embed)
async def voice_announcer(destination: str, voice_channel: discord.VoiceChannel) -> bool:
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 False
if voice_channel.guild.voice_client:
logger(f"Already in vc, skipping this announcement to be safe")
return False
logger(f"Joining vc, playing {sound_path}")
vc = await voice_channel.connect(timeout=15, reconnect=True)
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():
@@ -97,7 +112,7 @@ async def _schedule_next_transfer(bot: discord.Bot, arrival_dt: datetime, voice_
if wait_seconds > 0:
remaining = str(timedelta(seconds=wait_seconds))
logger(f"Next transfer in {remaining.split('.')[0]} ({arrival_dt.strftime('%H:%M')} Uhr)")
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
@@ -109,3 +124,50 @@ async def _schedule_next_transfer(bot: discord.Bot, arrival_dt: datetime, voice_
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))
+9 -8
View File
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: de.yaml
# timestamp: 2026-08-17T18:44:37+00:00
# filename: en.yaml
# timestamp: 2026-08-23T14:08:25+00:00
from __future__ import annotations
@@ -16,11 +16,7 @@ class LongName:
@dataclass
class Channel:
long_name: LongName
@dataclass
class Footer:
notice: str
short_name: str
@dataclass
@@ -44,12 +40,17 @@ class Announcement:
@dataclass
class Embeds:
footer: Footer
info: Info
announcement: Announcement
@dataclass
class RichPresence:
arrival: str
@dataclass
class Model:
channel: Channel
embeds: Embeds
rich_presence: RichPresence
+86 -28
View File
@@ -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
@@ -30,15 +30,17 @@ def choose_connection() -> dict | None:
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"Connection is from the past: {start_dt}", "error")
logger(f"Connection is from the past: {start_local}", "error")
return False
max_wait_time = config.connections.max_wait_time
if max_wait_time:
if start_dt > now + timedelta(hours=max_wait_time):
logger(f"Connection is way too far in the future: {start_dt} (max_wait_time: {max_wait_time}h)", "error")
logger(f"Connection is way too far in the future: {start_local} (max_wait_time: {max_wait_time}h), retrying...", "error")
return False
departure_time_iso_dt = datetime.fromisoformat(departure_time_iso.replace("Z", "+00:00"))
@@ -48,14 +50,14 @@ def validate_connection(start_time: str, end_time: str, departure_time_iso: str)
min_duration = config.connections.min_duration
min_duration_seconds = min_duration * 60
if trip_duration < min_duration_seconds:
logger(f"Connection is with {trip_duration_minutes} minutes too short (configured to {min_duration} minutes or more)", "error")
logger(f"Connection is with {trip_duration_minutes} minutes too short (configured to {min_duration} minutes or more), retrying...", "error")
return False
max_duration = config.connections.max_duration
if max_duration:
max_duration_seconds = max_duration * 60
if max_duration_seconds < trip_duration:
logger(f"Connection is with {trip_duration_minutes} too long (configured to {max_duration} minutes at most)", "error")
logger(f"Connection is with {trip_duration_minutes} too long (configured to {max_duration} minutes at most), retrying...", "error")
return False
return True
@@ -122,28 +124,14 @@ def get_operator_metadata(agency: str, route_color: str, mode: 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"Couldn't find {sound_path}", "error")
return None
return sound_path
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)
@@ -157,6 +145,7 @@ def get_next_station(stops: dict, train_from: str) -> dict | None:
"name": name,
"arrival": arrival_dt
}
return None
def format_via_list(stops: dict, via_and: str) -> str:
@@ -196,10 +185,10 @@ def format_stop_list(stops: dict, next_stop: str | None) -> list[tuple[str, str]
for name, info in stops.items():
if name == next_stop:
stop_arrival = info["arrival"]
line = f"• __{name}__ ({stop_arrival.strftime("%H:%M")} Uhr)"
line = f"• __{name}__ ({stop_arrival.strftime("%H:%M")})"
else:
stop_arrival = info["arrival"]
line = f"{name} ({stop_arrival.strftime("%H:%M")} Uhr)"
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)"
@@ -214,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!")