Compare commits

..
28 Commits
Author SHA1 Message Date
Kaaninchen 27f8ef2cbc transitous rewrite: /info improvements 2026-08-16 23:29:03 +02:00
Kaaninchen 36a1db0647 transitous rewrite: add check_stations() function 2026-08-16 23:15:38 +02:00
Kaaninchen 8e9fa7c2f2 transitous rewrite: refactor config 2026-08-16 21:09:28 +02:00
Kaaninchen 41e185a211 transitous rewrite: code cleanup 2026-08-16 20:18:16 +02:00
Kaaninchen 66289e8572 transitous rewrite: next_stop functionality 2026-08-16 19:43:53 +02:00
Kaaninchen ff8c2f55db transitous rewrite: improved station selection 2026-08-16 19:12:20 +02:00
Kaaninchen ca3f93f4cd transitous rewrite: split /info embed if more than 1024 characters 2026-08-16 18:05:31 +02:00
Kaaninchen cdceeb4e44 transitous rewrite: fix default duration values 2026-08-16 17:46:46 +02:00
Kaaninchen 2ec38c1874 transitous rewrite: announcer improvements 2026-08-16 17:33:18 +02:00
Kaaninchen c563062b9a transitous rewrite: announcements 2026-08-16 17:22:50 +02:00
Kaaninchen a75b12ea31 transitous rewrite: via 2026-08-16 15:59:08 +02:00
Kaaninchen 1b0546ff4d transitous rewrite: add max_duration 2026-08-16 15:20:07 +02:00
Kaaninchen 4598dcdc08 transitous rewrite: add logo license 2026-08-16 14:51:28 +02:00
Kaaninchen 10e1ea8ade transitous rewrite: minimal route length 2026-08-16 14:50:11 +02:00
Kaaninchen a4d9cb36f0 transitous rewrite: fix color from API 2026-08-16 14:23:56 +02:00
Kaaninchen 5a2d0bf8d6 transitous rewrite: bug fixes
don't know anymore :(
2026-08-16 14:04:55 +02:00
Kaaninchen 1b0b10dc45 transitous rewrite: validate connections 2026-08-16 13:11:05 +02:00
Kaaninchen 1892c734cd transitous rewrite: fix params string 2026-08-16 11:00:31 +02:00
Kaaninchen f3d9a25f95 transitous rewrite: make sure to only use connections in the future 2026-08-16 10:43:38 +02:00
Kaaninchen 53358c08b1 transitous rewrite: remove wtf.json 2026-08-15 22:58:32 +02:00
Kaaninchen 46fe9eb61e transitous rewrite: bug fixes in the info embed 2026-08-15 22:55:57 +02:00
Kaaninchen 697a0f6218 transitous rewrite: some minor info embed improvements 2026-08-15 22:13:58 +02:00
Kaaninchen cf696df5f5 transitous rewrite: remove ID from some german regional trains
the API gave a name like RB69 (IDNUMBERS) for RE and RB trains
2026-08-15 20:06:47 +02:00
Kaaninchen f2a686edca transitous rewrite: fix tram emoji 2026-08-15 20:06:04 +02:00
Kaaninchen 8770d03bb6 transitous rewrite: channel formatting 2026-08-15 19:48:15 +02:00
Kaaninchen 826b543347 transitous: basic rename_vc functionality 2026-08-15 19:12:30 +02:00
Kaaninchen adfa0a0440 transitous: add api functions 2026-08-15 17:47:40 +02:00
Kaaninchen 5f6c3a223b transitous: add bruno 2026-08-15 14:19:57 +02:00
19 changed files with 1524 additions and 478 deletions
+1
View File
@@ -3,3 +3,4 @@ __pycache__/
src/data/announcements/*
!src/data/announcements/.gitkeep
config.json
stations.json
-4
View File
@@ -1,8 +1,4 @@
# Gleiswechsel
## THIS BRANCH IS NO LONGER MAINTAINED
## This is the code for the legacy Gleiswechsel bot, based on the much simpler dbf API.
Gleiswechel ist ein Discord Bot, welcher einen Sprachkanal zu einer real-existierenden, aktuell befahrenden Zugverbindung umbenennt. Diesen Namen behält der Kanal so lange, wie die Verbindung in echt dauert.
![Beispiel Kanal](.github/preview_kanal.png)
+21 -8
View File
@@ -1,20 +1,33 @@
{
"discord": {
"token": "",
"stations": ["Hamburg Hbf", "München Hbf", "Köln Hbf", "Amsterdam Centraal"],
"dbf": "https://dbf.finalrewind.org",
"server": ,
"vc": ,
"random": true,
"emojis": true,
"formatting": "┇",
"announcements": true,
"voice_announcements: [
"emojis": true
},
"connections": {
"stations": [
""
],
"blacklist": [],
"min_duration": 5,
"max_duration": null,
"max_wait_time": 6,
"timezone": "Europe/Berlin"
},
"announcements": {
"enabled": true,
"voice": [
{
"enabled": false,
"stations": {
"general": ""
"general": "general.aac",
}
}
]
"blacklist": []
},
"http": {
"user_agent": "Gleiswechsel-Discord-Bot"
}
}
+698
View File
@@ -0,0 +1,698 @@
{
"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": []
}
]
}
+31 -26
View File
@@ -1,40 +1,45 @@
import discord
import random
from discord.ext import tasks
from src.config import config
from src.handlers import rename_vc
from src.commands import setup_commands
from src.embeds import build_error_embed
import sys
from src.utils import logger
from src.data.status import discord_status
from src.config import config
from src.dc.handlers import rename_vc
from src.dc.helpers import validate_channel
from src.dc.commands import setup_commands
from src.api.transitous import check_stations
bot = discord.Bot(intents=discord.Intents.all())
setup_commands(bot)
@tasks.loop(minutes=5)
async def change_status():
status = random.choice(discord_status)
await bot.change_presence(activity=discord.Game(name=f"{status} • /info"))
setup_commands(bot=bot)
_bot_initialized = False
if len(sys.argv) > 1:
if sys.argv[1] == "stations":
check_stations()
sys.exit(0)
@bot.event
async def on_ready():
global _bot_initialized
print(f"{bot.user} ist online")
if not change_status.is_running():
change_status.start()
logger(f"{bot.user} ist online")
if not _bot_initialized:
_bot_initialized = True
await rename_vc(bot)
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("Discord Reconnect, laufende Fahrt bleibt unangetastet")
@bot.event
async def on_application_command_error(ctx, error):
embed = build_error_embed(f"Ein Fehler ist aufgetreten: {error}")
await ctx.respond(embed=embed)
logger("Reconnected to discord gateway, this wont disturb your current ride")
try:
bot.run(config['token'])
bot.run(config.discord.token)
except:
logger("Fehler beim parsen des token", "fatal")
logger("Feher peim parsen des tokens", "fatal")
'''
TODO
- discord status
- multi language support
- README
'''
+253
View File
@@ -0,0 +1,253 @@
import requests
import random
import json
from datetime import datetime, timezone
from src.utils import logger, get_train_name, convert_iso_string, validate_connection
from src.config import config
stations = config.connections.stations
blacklist = config.connections.blacklist
user_agent = config.http.user_agent
headers = {
"User-Agent": f"{user_agent}"
}
endpoint = "https://api.transitous.org"
def check_stations():
all_stations_output = {}
minimal_overview = {
"stations": {}
}
for station in stations:
req = f"{endpoint}/api/v1/geocode"
try:
response = requests.get(req, params={"text": station}, headers=headers)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(f"An error occured while checking for station {station}: {e}")
continue
stops_dict = {}
aliases_list = []
exact_match = False
for entry in data:
if entry.get("type") != "STOP":
continue
station_id = entry.get("id")
stop_name = entry.get("name")
coords = f"{entry.get("lat")}, {entry.get("lon")}"
modes = entry.get("modes")
stop_details = {
"tz": entry.get("tz"),
"country": entry.get("country"),
"coords": coords,
"modes": modes,
"id": station_id,
}
if stop_name:
stops_dict[stop_name] = stop_details
aliases_list.append(stop_name)
if stop_name == station:
if not exact_match:
logger(f"Exact match found! {station} is an assigned station! Bot would use that station directly")
exact_match = True
if not stops_dict:
logger("Failed to grab ID from 'search_name'", "error")
continue
all_stations_output[station] = {
"associated": stops_dict
}
minimal_overview["stations"][station] = aliases_list
logger(json.dumps(minimal_overview, indent=4, ensure_ascii=False))
print(f"\nIf you want, I can save a more detailed version directly as a json file to disk.")
print("The json would provide informations like coords, country and transport modes that are from every specific associated station.")
prompt = input("This would help you to identify the associated stations more accurately (y/n): ")
if prompt == "y" or prompt == "yes":
with open('stations.json', 'w') as f:
json.dump(all_stations_output, f, indent=4, ensure_ascii=False)
logger("stations.json generated")
else:
logger("okay :(")
def get_random_stop_id() -> str | None:
assigned_station = random.choice(stations)
req = f"{endpoint}/api/v1/geocode"
try:
response = requests.get(req, params={"text": assigned_station}, headers=headers)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(f"An error occured while searching for a connection: {e}", "error")
return None
stop_ids = {}
for entry in data:
if entry.get("type") != "STOP":
continue
stop_name = entry.get("name")
stop_id = entry.get("id", None)
stop_ids[stop_id] = stop_name
if stop_name == assigned_station:
stop_ids.clear()
stop_ids[stop_id] = stop_name
break
if stop_ids is None:
logger(f"Failed to grab ID from '{assigned_station}'", "error")
return None
stop_ids_list = list(stop_ids.keys())
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)
return chosen_stop_id
def get_random_connection(stop_id: str) -> str | None:
if stop_id is None:
return None
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
cursor = None
max_pages = 5
min_results = 5
trip_ids = []
all_stop_times = []
for _ in range(max_pages):
params = {
"stopId": stop_id,
"n": 20,
"time": now,
}
if cursor:
params["pageCursor"] = cursor
try:
response = requests.get(f"{endpoint}/api/v5/stoptimes", params=params, headers=headers)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(e, "Error")
break
stop_times = data.get("stopTimes", [])
all_stop_times.extend(stop_times)
for entry in stop_times:
trip_id = entry["tripId"]
if entry["mode"] in blacklist:
continue
else:
trip_ids.append(entry["tripId"])
if len(trip_ids) >= min_results:
break
cursor = data.get("nextPageCursor")
if not cursor:
break
if not trip_ids:
logger("Couldn't find any connection", "error")
return None
trip_id = random.choice(trip_ids)
from_station = None
for entry in all_stop_times:
if entry.get("tripId") == trip_id:
from_station = entry.get("place", {}).get("name")
break
return {
"trip_id": trip_id,
"from_station": from_station
}
def get_trip_details(random_connection: dict | None) -> dict | None:
if random_connection is None:
return None
req = f"{endpoint}/api/v2/trip"
try:
response = requests.get(req, params={"tripId": random_connection["trip_id"]}, headers=headers)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(f"An error occured while trying to get the route details: {e}", "error")
return None
legs = data["legs"][0]
end_time = legs["endTime"]
from_station = random_connection["from_station"]
display_name = legs["displayName"]
train_from = legs["from"]["name"]
goes_to = legs["to"]["name"]
start_time = legs["startTime"]
mode = legs["mode"]
arrival = convert_iso_string(end_time)
departure = convert_iso_string(start_time)
train_name = get_train_name(display_name, mode)
if train_from == from_station:
long_name = f"{train_name} nach {goes_to} von {train_from}"
else:
long_name = f"{train_name} nach {goes_to} über {from_station}"
trip_details = {
"long_name": long_name,
"short_name": display_name,
"station": from_station,
"from": train_from,
"to": goes_to,
"agency": legs["agencyName"],
"route_color": legs.get("routeColor"),
"duration": legs["duration"],
"departure": departure,
"arrival": arrival,
"mode": mode,
"stops": {}
}
trip_details["stops"][train_from] = departure
departure_time = 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
valid = validate_connection(start_time, end_time, departure_time)
if not valid:
return None
logger(json.dumps(trip_details, indent=4, ensure_ascii=False))
return trip_details
-12
View File
@@ -1,12 +0,0 @@
import discord
from discord.ext import commands
from src.embeds import build_info_embed, build_error_embed
def setup_commands(bot: discord.Bot):
@bot.slash_command(description="Informationen über die aktuelle Fahrt")
async def info(ctx):
embed = build_info_embed()
if embed is None:
await build_error_embed("Noch keine Verbindung gesetzt.")
return
await ctx.respond(embed=embed)
+56 -2
View File
@@ -1,4 +1,58 @@
import json
import os
from dataclasses import dataclass, field
from typing import Optional
with open("config.json", "r") as file:
config = json.load(file)
@dataclass
class DiscordConfig:
token: str
server: int
vc: int
formatting: str
emojis: bool
@dataclass
class ConnectionsConfig:
stations: list[str]
blacklist: list[str]
min_duration: int
max_wait_time: int
timezone: str
max_duration: Optional[int] = None
@dataclass
class VoiceAnnouncementConfig:
enabled: bool
stations: dict[str, str]
@dataclass
class AnnouncementConfig:
enabled: bool
voice: list[VoiceAnnouncementConfig]
@dataclass
class HttpConfig:
user_agent: str
@dataclass
class Config:
discord: DiscordConfig
connections: ConnectionsConfig
announcements: AnnouncementConfig
http: HttpConfig
def _load_config() -> Config:
with open("config.json", "r") as file:
raw = json.load(file)
return Config(
discord=DiscordConfig(**raw["discord"]),
connections=ConnectionsConfig(**raw["connections"]),
announcements=AnnouncementConfig(
enabled=raw["announcements"]["enabled"],
voice=[VoiceAnnouncementConfig(**v) for v in raw["announcements"]["voice"]],
),
http=HttpConfig(**raw["http"]),
)
config = _load_config()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2024 Mathis Brüchert <[email protected]>
SPDX-License-Identifier: CC-BY-SA-4.0
+9 -16
View File
@@ -1,18 +1,11 @@
emoji_list = {
"Fallback": "🚉",
"Nahverkehr": "🚃",
"Höchstgeschwindigkeit": "🚄",
"Bus": "🚎"
}
train_types = { # Unvollständig!
"ICE": emoji_list["Höchstgeschwindigkeit"],
"TGV": emoji_list["Höchstgeschwindigkeit"],
"RE": emoji_list["Nahverkehr"],
"RB": emoji_list["Nahverkehr"],
"ARV": emoji_list["Nahverkehr"],
"BRB": emoji_list["Nahverkehr"],
"Bus": emoji_list["Bus"]
"Fallback": "💺",
"BUS": "🚎",
"COACH": "🚎",
"TRAM": "🚈",
"REGIONAL_RAIL": "🚊",
"HIGHSPEED_RAIL": "🚅",
"LONG_DISTANCE": "🚅",
"METRO": "🚇",
"SUBWAY": "🚇"
}
+16 -2
View File
@@ -38,7 +38,7 @@ OPERATORS = {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/ODEG-Logo_Neu.svg/960px-ODEG-Logo_Neu.svg.png",
"color": 0x00745C
},
"Nederlandse Spoorwegen": {
"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"]
@@ -98,6 +98,14 @@ OPERATORS = {
"enno": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Enno_logo.svg/960px-Enno_logo.svg.png",
"color": 0x88216F
},
"DSB": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/8/87/DSB_company_logo.svg/960px-DSB_company_logo.svg.png",
"color": 0xB22B32
},
"Vr": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/Vr_Logo.png/330px-Vr_Logo.png",
"color": 0x00B451
}
}
@@ -109,10 +117,16 @@ OPERATOR_ALIASES = {
"DB Regio AG Bayern": OPERATORS["db_bayern"],
"DB Fernverkehr AG": OPERATORS["db_allgemein"],
"DB Regio AG NRW": OPERATORS["db_allgemein"],
"DB Regio AG Nord": OPERATORS["db_allgemein"],
"DB Regio AG Südost": OPERATORS["db_allgemein"],
"DB Regio AG Nordost": OPERATORS["db_allgemein"],
"DB Regio AG Nord": OPERATORS["db_allgemein"],
"DB Regio AG Mitte": OPERATORS["db_allgemein"],
"NS International": OPERATORS["NS"],
"SBB GmbH": OPERATORS["SBB"],
"Schweizerische Bundesbahnen SBB": OPERATORS["SBB"],
"Dänische Staatsbahnen": OPERATORS["DSB"],
"VR": OPERATORS["Vr"]
}
+7
View File
@@ -0,0 +1,7 @@
import discord
from src.dc.embeds import build_info_embed
def setup_commands(bot: discord.Bot):
@bot.slash_command(description="Informationen über die aktuelle Fahrt")
async def info(ctx):
await ctx.respond(embed=build_info_embed())
+79
View File
@@ -0,0 +1,79 @@
import discord
import random
from src.utils import get_operator_metadata, get_next_station, format_via_list, format_stop_list
from src.dc.helpers import format_timestamp_to_dc
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"
if slogans is not None:
footer_text = f"{random.choice(slogans)}{footer_notice}"
else:
footer_text = footer_notice
return {
"text": footer_text,
"icon": icon
}
def build_info_embed() -> discord.Embed:
from src.dc.handlers import trip
agency = trip["agency"]
metadata = get_operator_metadata(agency, trip["route_color"])
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}",
color = metadata["color"]
)
stops = trip["stops"]
next_stop = get_next_station(trip["stops"])
print(f"next_stop: {next_stop}")
next_stop_station = None
if next_stop:
next_stop_station = next_stop.get("name")
via = format_via_list(stops)
if via:
embed.add_field(name="Über", value=via, inline=False)
if next_stop:
embed.add_field(name="Nächster Halt", value=f"__{next_stop_station}__", inline=False)
route_fields = format_stop_list(stops, next_stop_station)
for field_name, field_value in route_fields:
embed.add_field(name=field_name, value=field_value, inline=False)
footer = build_embed_footer(trip["mode"], metadata["slogans"])
embed.set_footer(text=footer["text"], icon_url=footer["icon"])
embed.set_author(name=agency)
embed.set_thumbnail(url=metadata["logo"])
return embed
def build_announcement_embed(msg):
from src.dc.handlers import trip
agency = trip["agency"]
metadata = get_operator_metadata(agency, trip["route_color"])
embed = discord.Embed(
title = "Informationen zu ihrer Fahrt",
description=msg,
color=metadata["color"]
)
embed.set_author(name=agency)
embed.set_thumbnail(url=metadata["logo"])
footer = build_embed_footer(trip["mode"], metadata.get("slogan"))
embed.set_footer(text=footer["text"], icon_url=footer["icon"])
return embed
+117
View File
@@ -0,0 +1,117 @@
import discord
import asyncio
import random
from datetime import datetime, timedelta, date
from src.utils import logger, channel_formatting, choose_connection, get_sound_path
from src.config import config
_scheduled_task: asyncio.Task | None = None
async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = False):
global trip, _scheduled_task
if not from_scheduler and _scheduled_task and not _scheduled_task.done():
_scheduled_task.cancel()
attempt = 0
max_attempt = 15
trip = choose_connection()
while trip is None and attempt < max_attempt:
attempt += 1
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
arrival = trip["arrival"]
long_name = trip["long_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!)")
formatting = channel_formatting(mode)
await voice_channel.edit(name=f"{formatting}{long_name}")
await voice_channel.set_status(f"Ankunft um {arrival}")
logger(f"Name geändert!")
await announcer("umstieg", 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)
async def announcer(announcement: str, voice_channel: discord.VoiceChannel, destination = None):
from src.dc.embeds import build_info_embed, build_announcement_embed
announcements_enabled = config.announcements.enabled
voice_announcement_enabled = config.announcements.voice[0].enabled
if announcements_enabled:
if len(voice_channel.members) > 0:
match announcement:
case "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":
embed = build_info_embed()
case _:
logger(f"Unbekanntes Announcements: {announcement}")
embed = None
if embed:
await voice_channel.send(embed=embed)
else:
logger(f"Announcement {announcement} wird geskipped, keiner da")
return
async def voice_announcer(destination: str, voice_channel: discord.VoiceChannel):
sound_path = get_sound_path(destination=destination)
if sound_path is None:
return
logger(f"VC wird betreten, spiele {sound_path}")
vc = await voice_channel.connect(timeout=15, reconnect=True)
audio_source = discord.FFmpegPCMAudio(sound_path)
loop = asyncio.get_running_loop()
if not vc.is_playing():
def after_playing(error):
if error:
logger(f"Player error: {error}", "error")
loop.create_task(vc.disconnect())
logger("VC wird verlassen")
vc.play(audio_source, after=after_playing)
+32
View File
@@ -0,0 +1,32 @@
import discord
from datetime import datetime, timedelta
from src.utils import logger
def validate_channel(bot: discord.bot, server_id: int, channel_id: int):
guild = bot.get_guild(server_id)
if guild is None:
logger(f"Es konnte kein Server mit der ID {server_id} gefunden werden", "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")
return False
return channel
def format_timestamp_to_dc(timestr):
parsed_time = datetime.strptime(timestr, "%H:%M")
now = datetime.now()
final_datetime = datetime.now().replace(
hour=parsed_time.hour,
minute=parsed_time.minute,
second=0,
microsecond=0
)
if final_datetime <= now:
final_datetime += timedelta(days=1)
return discord.utils.format_dt(final_datetime, style="t")
-109
View File
@@ -1,109 +0,0 @@
import random
import discord
from datetime import datetime, timedelta
import src.handlers as handlers
from src.config import config
from src.utils import operator_metadata, format_via_list, resolve_operator, logger
def format_timestamp(timestr):
parsed_time = datetime.strptime(timestr, "%H:%M")
now = datetime.now()
final_datetime = datetime.now().replace(
hour=parsed_time.hour,
minute=parsed_time.minute,
second=0,
microsecond=0
)
if final_datetime <= now:
final_datetime += timedelta(days=1)
return discord.utils.format_dt(final_datetime, style="t")
def format_iso_timestamp(isostr):
parsed_time = discord.utils.parse_time(isostr)
return discord.utils.format_dt(parsed_time, style="t")
def build_embed_footer(operator_slogans):
dbf = config.get("dbf", "https://dbf.finalrewind.org")
footer_notice = f"Daten großzügig bereitgestellt von {dbf} • Typ: {handlers.train_type}"
footer_text = (
f"{random.choice(operator_slogans)}{footer_notice}"
if operator_slogans else
footer_notice
)
icon = f"{dbf}/static/icons/icon-96x96.png"
return {
"text": footer_text,
"icon": icon
}
def build_info_embed() -> discord.Embed | None:
conn = handlers.current
info = handlers.train_info
if handlers.current is None and handlers.train_info is None:
return None
current_operator = resolve_operator(info["operators"])
arrival = format_iso_timestamp(info["arrival"])
departure = format_timestamp(conn['departure'])
operator_infos = operator_metadata(current_operator)
embed = discord.Embed(
title=handlers.train_name,
description=f"Abfahrt von {conn['station']} um {departure}. Ankunft um {arrival}",
color=operator_infos["color"]
)
if len(conn['via']) > 0:
embed.add_field(name="Über", value=format_via_list(conn['via']), inline=False)
if operator_infos.get("unknown"):
logger(f"Metadaten für {current_operator} konten in operators.py nicht gefunden werden")
embed.set_author(name=current_operator)
embed.set_thumbnail(url=operator_infos["logo"])
route_lines = []
for stop in conn['route']:
stop_name = stop["name"]
if stop_name == conn['station']:
route_lines.append(f"• **{stop_name}**")
else:
route_lines.append(f"{stop_name}")
embed.add_field(name="Route", value="\n".join(route_lines))
footer = build_embed_footer(operator_infos.get("slogan"))
embed.set_footer(text=footer["text"], icon_url=footer["icon"])
return embed
def build_announcement_embed(msg):
current_operator = resolve_operator(handlers.train_info["operators"])
operator_infos = operator_metadata(current_operator)
embed = discord.Embed(
title = "Informationen zu Ihrer Fahrt",
description=msg,
color=operator_infos["color"]
)
embed.set_author(name=current_operator)
embed.set_thumbnail(url=operator_infos["logo"])
footer = build_embed_footer(operator_infos.get("slogan"))
embed.set_footer(text=footer["text"], icon_url=footer["icon"])
return embed
def build_error_embed(errormsg) -> discord.Embed:
embed = discord.Embed(
title="Ein Fehler ist aufgetreten!",
description=errormsg,
color=discord.Colour.red()
)
return embed
-154
View File
@@ -1,154 +0,0 @@
import discord
from datetime import datetime, timedelta
import asyncio
import random
from pathlib import Path
from src.config import config
from src.utils import random_connection, get_train_info, get_channel_formatting, logger
from src.embeds import build_announcement_embed, build_info_embed
current = None
_scheduled_task: asyncio.Task | None = None
async def announcer(bot, announcement):
voice_channel = bot.get_channel(config["vc"])
if len(voice_channel.members) > 0:
match announcement:
case "ende":
destination = current['destination']
embed = build_announcement_embed(
f'Sehr geehrte Fahrgäste,\nIn wenigen Minuten erreichen wir {destination}. Dieser Zug endet dort.\n\nWir wünschen Ihnen eine angenehme Weiterreise.\n\nVielen Dank für Ihr Vertrauen und auf Wiedersehen.')
if config["voice_announcements"][0]["enabled"]:
await voice_announcer(bot, destination, voice_channel)
case "umstieg":
embed = build_info_embed()
case _:
embed = None
if embed is None:
logger(f"Unbekanntes Announcement: {announcement}")
else:
await voice_channel.send(embed=embed)
else:
logger(f"Announcement {announcement} wird geskipped, keiner da")
return
async def voice_announcer(bot: discord.Bot, destination, voice_channel):
voice_announcement_config = config["voice_announcements"][0]
voice_stations = voice_announcement_config["stations"]
if destination in voice_stations:
announcement_for = destination
else:
if voice_stations.get("general", "") == "":
return
announcement_for = "general"
if voice_stations.values() == list:
sound_file = random.choice(voice_stations.get(announcement_for))
else:
sound_file = voice_stations.get(announcement_for)
sound_path = f"src/data/announcements/{sound_file}"
if Path(sound_path).is_file() is False:
logger(f"Konnte Datei {sound_path} nicht finden", "error")
return
logger(f"VC wird betreten, spiele {sound_path}")
vc = await voice_channel.connect(timeout=15, reconnect=True)
audio_source = discord.FFmpegPCMAudio(sound_path)
if not vc.is_playing():
def after_playing(error):
if error:
logger(f"Player error: {error}", "error")
bot.loop.create_task(vc.disconnect())
logger("VC wird verlassen")
vc.play(audio_source, after=after_playing)
async def rename_vc(bot: discord.Bot, from_scheduler: bool = False):
global current, train_name, train_info, train_type, _scheduled_task
guild = bot.get_guild(int(config["server"]))
if guild is None:
logger(f"Es konnte kein Server mit der ID {config['server']} gefunden werden! Ist der Bot ein Member?", "fatal")
return False
channel = guild.get_channel(int(config["vc"]))
if not isinstance(channel, discord.VoiceChannel):
logger(f"Es konnte kein VC mit der ID {config['vc']} auf dem Server gefunden werden", "fatal")
return False
if not from_scheduler and _scheduled_task and not _scheduled_task.done():
_scheduled_task.cancel()
attempt = 0
while True:
if attempt == 20:
logger("Zu viele Fehlversuche. Füge einen anderen Bahnhof hinzu.", "fatal")
return "Es konnte kein Zug gefunden werden."
attempt += 1
current = random_connection()
if current == None:
return None
parts = current['train'].split()
train_type = parts[0]
if parts[1].isdigit():
train = current['train']
train_ID = parts[1]
else:
train = parts[1]
train_ID = current['train_number']
station = current['station']
train_info = get_train_info(station=station, train_ID=train_ID, train_type=train_type)
if train_info and train_info.get('operators') and train_info.get('arrival'):
break
logger(f"Versuch {attempt}: Fehler bei {current['train']} von {station}, versuche neue Verbindung...")
train_name = f"{train} nach {current['destination']} von {current['station']}"
logger(f"Vorbereitung auf {train_name} (typ: {train_type})")
arrival = datetime.fromisoformat((train_info["arrival"]))
formatting = get_channel_formatting(train_type)
print("-----------------------------------------")
logger(f"Umstieg: {train_name}")
logger(f"Betreiber: {''.join(train_info['operators'])}")
logger(f"Train-Type: {train_type}")
logger(f"Wenn der Name nicht geändert wird bin ich im Cooldown")
await channel.edit(name=f"{formatting}{train_name}")
await channel.set_status(f"Ankunft um {arrival.strftime('%H:%M')}")
logger(f"Name geändert!")
if config.get("announcements", True):
await announcer(bot, "umstieg")
_scheduled_task = asyncio.create_task(_schedule_next_umstieg(bot, arrival))
return True
async def _schedule_next_umstieg(bot, arrival):
announcement = config.get("announcements", True)
announcement_countdown = random.randrange(180, 300) # letzte station announcement ist meistens 3-5min vor ankunft
wait_seconds = (arrival - datetime.now()).total_seconds()
if wait_seconds > 0:
remaining = str(timedelta(seconds=wait_seconds))
logger(f"Nächster Umstieg in {remaining.split('.')[0]} ({arrival.strftime('%H:%M:%S')} Uhr)")
if announcement and wait_seconds > announcement_countdown:
wait_until_end_announcement = wait_seconds - announcement_countdown
await asyncio.sleep(wait_until_end_announcement)
await announcer(bot, "ende")
await asyncio.sleep(announcement_countdown)
else:
await asyncio.sleep(wait_seconds)
logger("Zug angekommen, wähle neue Verbindung...")
await rename_vc(bot, from_scheduler=True)
+194 -137
View File
@@ -1,149 +1,206 @@
import requests
import random
import os
import importlib
from datetime import datetime
import random
from pathlib import Path
from datetime import datetime, timedelta, timezone
from src.config import config
import src.data.operators as operators_module
import src.data.emojis as emojis
from zoneinfo import ZoneInfo
_operators_mtime = None
dbf = config.get("dbf", "https://dbf.finalrewind.org")
import src.data.operators as operators
from src.data.emojis import emoji_list
def _reload_operators_if_changed():
global _operators_mtime
_operator_mtime = None
path = operators_module.__file__
current_mtime = os.path.getmtime(path)
if _operators_mtime is None:
_operators_mtime = current_mtime
return
if current_mtime != _operators_mtime:
importlib.reload(operators_module)
_operators_mtime = current_mtime
logger("operators.py wurde automatisch neu geladen (Änderungen erkannt)")
def random_connection():
available_stations = config["stations"].copy()
while True:
if not available_stations:
logger("Keine validen Bahnhöfe. Schlag den richtigen Bahnhofsnamen auf {dbf} nach", "fatal")
station = random.choice(available_stations)
url = f"{dbf}/{station}.json"
blacklist = config.get("blacklist", [])
try:
response = requests.get(url)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(f"Fehler beim aussuchen der Verbindung: {e}", "fatal")
return
if response.status_code == 300:
logger(f"Bahnhof '{station}' konnte nicht gefunden werden ({url})", "error")
available_stations.remove(station)
continue
departures = [
d for d in data.get("departures", [])
if d.get("scheduledDeparture") and d.get("destination") != station
and not d.get("train", "").startswith(tuple(blacklist))
]
if not departures:
continue
if not config['random']:
dep = departures[0]
else:
dep = random.choice(departures)
return {
"train": dep['train'],
"destination": dep['destination'],
"route": dep['route'],
"departure": dep['scheduledDeparture'],
"via": dep['via'],
"station": station,
"train_number": dep['trainNumber']
}
def get_train_info(station, train_ID, train_type):
url = f"{dbf}/z/{train_type}%20{train_ID}/{station}.json"
logger(f"Fetche {url}")
try:
response = requests.get(url)
response.raise_for_status()
data = response.json()
except requests.RequestException as e:
logger(f"ReqestException Fehler: {e}")
return None
dep = data.get("departure", [])
if not dep:
logger("Kein departure Feld gefunden", "error")
return None
route_post = dep.get("route_post_diff")
if not route_post:
logger("Kein route_post_diff Feld gefunden", "error")
return None
arrival_iso = route_post[-1].get("sched_arr")
if not arrival_iso:
logger("Keine Ankunftszeit gefunden", "error")
return None
return {
"arrival": arrival_iso,
"operators": dep.get("operators"),
}
def format_via_list(via: list[str]):
if not via:
return ""
if len(via) == 1:
return via[0]
return ", ".join(via[:-1]) + " und " + via[-1]
def resolve_operator(operators):
if not operators:
return None
for op in operators:
if op in operators_module.OPERATOR_ALIASES or op in operators_module.OPERATORS:
return op
return operators[0]
def operator_metadata(operator):
_reload_operators_if_changed()
if not operator:
return operators_module.OPERATORS["fallback"]
if operator in operators_module.OPERATOR_ALIASES:
return operators_module.OPERATOR_ALIASES[operator]
return operators_module.OPERATORS.get(operator, operators_module.OPERATORS["fallback"])
def get_channel_formatting(train_type):
formatting = config.get("formatting", "")
train_emoji = ""
if config.get("emojis", True):
train_emoji = emojis.train_types.get(train_type)
if train_emoji is None:
train_emoji = emojis.emoji_list.get("Fallback", "")
return f"{train_emoji}{formatting}"
def logger(msg, log_type="info"):
def logger(msg, log_type="info") -> str:
status = log_type.upper()
current_time = datetime.now().strftime('%X')
print(f"{current_time}: {status}: {msg}")
if status == "FATAL":
os._exit(1)
def choose_connection() -> dict | None:
from src.api import transitous
station_id = transitous.get_random_stop_id()
connection = transitous.get_random_connection(station_id)
trip = transitous.get_trip_details(connection)
return trip
def validate_connection(start_time: str, end_time: str, station_departure: str) -> bool:
now = datetime.now(timezone.utc)
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")
return False
max_wait_time = config.connections.max_wait_time
start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
if start_dt > now + timedelta(hours=max_wait_time):
logger(f"Verbindung liegt zu weit in der Zukunft: {start_dt}", "error")
return False
station_departure_dt = datetime.fromisoformat(station_departure.replace("Z", "+00:00"))
trip_duration = (end_dt - station_departure_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")
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")
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 channel_formatting(mode: str) -> str:
formatting = config.discord.formatting
if config.discord.formatting:
emoji = emoji_list.get(mode)
if emoji is None:
emoji = emoji_list.get("Fallback")
return f"{emoji}{formatting}"
def get_train_name(train_name: str, mode: str) -> str:
if mode == "BUS" or mode == "TRAM":
train = f"{mode.capitalize()} {train_name}"
elif "(" in train_name:
train = train_name.split(" (")[0]
else:
train = train_name
return train
def _reload_operators_if_changed():
global _operator_mtime
path = operators.__file__
current_mtime = os.path.getmtime(path)
if _operator_mtime is None:
_operator_mtime = current_mtime
return
if current_mtime != _operator_mtime:
importlib.reload(operators)
_operator_mtime = current_mtime
logger("operators.py wurde automatisch neu geladen (Änderungen erkannt)")
def get_operator_metadata(agency: str, route_color: str) -> dict:
_reload_operators_if_changed()
op_data = operators.OPERATOR_ALIASES.get(agency) or operators.OPERATORS.get(agency) or operators.OPERATORS["fallback"]
logo = op_data.get("logo", operators.OPERATORS["fallback"]["logo"])
slogans = op_data.get("slogan")
color = op_data.get("color")
if color is None or color == operators.OPERATORS["fallback"]["color"]:
if route_color is not None:
try:
color = int(route_color, 16)
except ValueError:
color = operators.OPERATORS["fallback"]["color"]
else:
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 {
"logo": logo,
"color": color,
"slogans": slogans
}
def get_sound_path(destination) -> str | None:
voice_stations = config.announcements.voice[0].stations
if destination in voice_stations:
announcement_for = destination
else:
general_sound_enabled = voice_stations.get("general", "")
if not general_sound_enabled:
return None
announcement_for = "general"
if voice_stations.values() == list:
sound_file = random.choice(voice_stations.get(announcement_for))
else:
sound_file = voice_stations.get(announcement_for)
sound_path = f"src/data/announcements/{sound_file}"
if Path(sound_path).is_file() is False:
logger(f"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
if arrival_dt >= now:
return {"name": name, "arrival": arrival_str}
return None
def format_via_list(stops: dict) -> 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]}"
return via
return None
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():
if name == next_stop:
line = f"• __{name} ({stop_arrival} Uhr__)"
else:
line = f"{name} ({stop_arrival} Uhr)"
if field_length + len(line) + 1 > 1024:
route_page_name = "Route" if part == 1 else "Route (Fortsetzung)"
fields.append((route_page_name, "\n".join(field_lines)))
field_lines, field_length, part = [], 0, part + 1
field_lines.append(line)
field_length += len(line) + 1
if field_lines:
route_page_name = "Route" if part == 1 else "Route (Fortsetzung)"
fields.append((route_page_name, "\n".join(field_lines)))
return fields