Compare commits

...
17 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
14 changed files with 1199 additions and 99 deletions
+1 -2
View File
@@ -3,5 +3,4 @@ __pycache__/
src/data/announcements/* src/data/announcements/*
!src/data/announcements/.gitkeep !src/data/announcements/.gitkeep
config.json config.json
src/legacy/ stations.json
main_legacy.py
+26 -13
View File
@@ -1,20 +1,33 @@
{ {
"discord": {
"token": "", "token": "",
"stations": ["Hamburg Hbf", "München Hbf", "Köln Hbf", "Amsterdam Centraal"], "server": ,
"dbf": "https://dbf.finalrewind.org",
"server": ,
"vc": , "vc": ,
"random": true,
"emojis": true,
"formatting": "┇", "formatting": "┇",
"announcements": true, "emojis": true
"voice_announcements: [ },
{ "connections": {
"enabled": false, "stations": [
"stations": { ""
"general": "" ],
} "blacklist": [],
"min_duration": 5,
"max_duration": null,
"max_wait_time": 6,
"timezone": "Europe/Berlin"
},
"announcements": {
"enabled": true,
"voice": [
{
"enabled": false,
"stations": {
"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": []
}
]
}
+14 -10
View File
@@ -1,13 +1,22 @@
import discord import discord
from src.utils import config, logger import sys
from src.utils import logger
from src.config import config
from src.dc.handlers import rename_vc from src.dc.handlers import rename_vc
from src.dc.helpers import validate_channel from src.dc.helpers import validate_channel
from src.dc.commands import setup_commands from src.dc.commands import setup_commands
from src.api.transitous import check_stations
bot = discord.Bot(intents=discord.Intents.all()) bot = discord.Bot(intents=discord.Intents.all())
setup_commands(bot=bot) setup_commands(bot=bot)
_bot_initialized = False _bot_initialized = False
if len(sys.argv) > 1:
if sys.argv[1] == "stations":
check_stations()
sys.exit(0)
@bot.event @bot.event
async def on_ready(): async def on_ready():
global _bot_initialized global _bot_initialized
@@ -16,26 +25,21 @@ async def on_ready():
if not _bot_initialized: if not _bot_initialized:
_bot_initialized = True _bot_initialized = True
server_id = config["server"] server_id = config.discord.server
server_vc_id = config["vc"] server_vc_id = config.discord.vc
channel = validate_channel(bot=bot, server_id=server_id, channel_id=server_vc_id) channel = validate_channel(bot=bot, server_id=server_id, channel_id=server_vc_id)
await rename_vc(bot, voice_channel=channel) await rename_vc(bot, voice_channel=channel)
else: else:
logger("Reconnected to discord gateway, this wont disturb your current ride") logger("Reconnected to discord gateway, this wont disturb your current ride")
try: try:
bot.run(config["token"]) bot.run(config.discord.token)
except: except:
logger("Feher peim parsen des tokens", "fatal") logger("Feher peim parsen des tokens", "fatal")
''' '''
TODO TODO
- 1024 embed limit
- automatic reload of operators
- discord status - discord status
- text announcements
- voice announcements
- improved error handling (retry connection)
- multi language support - multi language support
- random = False - README
''' '''
+138 -32
View File
@@ -3,11 +3,12 @@ import random
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from src.utils import logger, config, get_train_name, convert_iso_string from src.utils import logger, get_train_name, convert_iso_string, validate_connection
from src.config import config
stations = config["stations"] stations = config.connections.stations
blacklist = config["blacklist"] blacklist = config.connections.blacklist
user_agent = config["http"]["user_agent"] user_agent = config.http.user_agent
headers = { headers = {
"User-Agent": f"{user_agent}" "User-Agent": f"{user_agent}"
@@ -15,7 +16,78 @@ headers = {
endpoint = "https://api.transitous.org" endpoint = "https://api.transitous.org"
def get_random_stop_id() -> str: 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) assigned_station = random.choice(stations)
req = f"{endpoint}/api/v1/geocode" req = f"{endpoint}/api/v1/geocode"
@@ -24,20 +96,40 @@ def get_random_stop_id() -> str:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
except requests.RequestException as e: except requests.RequestException as e:
logger(f"An error occured while searching for a connection: {e}", "fatal") logger(f"An error occured while searching for a connection: {e}", "error")
return None return None
if response.status_code == 404: stop_ids = {}
logger(f"Error finding station '{assigned_station}'")
for entry in data: for entry in data:
if entry.get("type") != "STOP": if entry.get("type") != "STOP":
continue continue
return entry["id"]
def get_random_connection(stop_id: str) -> str: 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") now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
print(f"Aktuelle Zeit für Query: {now}")
cursor = None cursor = None
max_pages = 5 max_pages = 5
min_results = 5 min_results = 5
@@ -58,19 +150,18 @@ def get_random_connection(stop_id: str) -> str:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
except requests.RequestException as e: except requests.RequestException as e:
print(e) logger(e, "Error")
break break
stop_times = data.get("stopTimes", []) stop_times = data.get("stopTimes", [])
all_stop_times.extend(stop_times) all_stop_times.extend(stop_times)
for entry in stop_times: for entry in stop_times:
dep = entry.get("place", {}).get("departure") or entry.get("place", {}).get("arrival")
print(f"{entry.get('mode')}: {convert_iso_string(dep)}")
trip_id = entry["tripId"] trip_id = entry["tripId"]
if entry["mode"] in blacklist: if entry["mode"] in blacklist:
continue continue
trip_ids.append(entry["tripId"]) else:
trip_ids.append(entry["tripId"])
if len(trip_ids) >= min_results: if len(trip_ids) >= min_results:
break break
@@ -80,9 +171,8 @@ def get_random_connection(stop_id: str) -> str:
break break
if not trip_ids: if not trip_ids:
logger("Couldn't find any connection", "fatal") logger("Couldn't find any connection", "error")
return None return None
trip_id = random.choice(trip_ids) trip_id = random.choice(trip_ids)
from_station = None from_station = None
@@ -96,34 +186,43 @@ def get_random_connection(stop_id: str) -> str:
"from_station": from_station "from_station": from_station
} }
def get_trip_details(trip_id: str, from_station: str) -> dict: def get_trip_details(random_connection: dict | None) -> dict | None:
if random_connection is None:
return None
req = f"{endpoint}/api/v2/trip" req = f"{endpoint}/api/v2/trip"
try: try:
response = requests.get(req, params={"tripId": trip_id}, headers=headers) response = requests.get(req, params={"tripId": random_connection["trip_id"]}, headers=headers)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
except requests.RequestException as e: except requests.RequestException as e:
logger(f"An error occured while trying to get the route details: {e}", "fatal") logger(f"An error occured while trying to get the route details: {e}", "error")
return None return None
legs = data["legs"][0] legs = data["legs"][0]
display_name = legs["displayName"]
trip_from = legs["tripFrom"]["name"]
goes_to = legs["tripTo"]["name"]
start_time = legs["startTime"]
end_time = legs["endTime"] 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"] mode = legs["mode"]
departure = convert_iso_string(start_time)
arrival = convert_iso_string(end_time) arrival = convert_iso_string(end_time)
departure = convert_iso_string(start_time)
train_name = get_train_name(display_name, mode) 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 = { trip_details = {
"long_name": f"{train_name} nach {goes_to} von {from_station}", "long_name": long_name,
"short_name": display_name, "short_name": display_name,
"from": from_station, "station": from_station,
"from": train_from,
"to": goes_to, "to": goes_to,
"agency": legs["agencyName"], "agency": legs["agencyName"],
"route_color": legs.get("routeColor"), "route_color": legs.get("routeColor"),
@@ -134,14 +233,21 @@ def get_trip_details(trip_id: str, from_station: str) -> dict:
"stops": {} "stops": {}
} }
trip_details["stops"][trip_from] = departure trip_details["stops"][train_from] = departure
departure_time = start_time
for stop in legs["intermediateStops"]: for stop in legs["intermediateStops"]:
stop_arrival = convert_iso_string(stop["arrival"]) stop_arrival = convert_iso_string(stop["arrival"])
trip_details["stops"][stop["name"]] = 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: if stop.get("name") == from_station:
departure_time = stop["departure"] departure_time = stop["departure"]
trip_details["departure"] = convert_iso_string(departure_time) trip_details["departure"] = convert_iso_string(departure_time)
trip_details["stops"][goes_to] = arrival 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 return trip_details
+58
View File
@@ -0,0 +1,58 @@
import json
import os
from dataclasses import dataclass, field
from typing import Optional
@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
+1
View File
@@ -5,6 +5,7 @@ emoji_list = {
"TRAM": "🚈", "TRAM": "🚈",
"REGIONAL_RAIL": "🚊", "REGIONAL_RAIL": "🚊",
"HIGHSPEED_RAIL": "🚅", "HIGHSPEED_RAIL": "🚅",
"LONG_DISTANCE": "🚅",
"METRO": "🚇", "METRO": "🚇",
"SUBWAY": "🚇" "SUBWAY": "🚇"
} }
+12
View File
@@ -98,6 +98,14 @@ OPERATORS = {
"enno": { "enno": {
"logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Enno_logo.svg/960px-Enno_logo.svg.png", "logo": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Enno_logo.svg/960px-Enno_logo.svg.png",
"color": 0x88216F "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
} }
} }
@@ -111,9 +119,13 @@ OPERATOR_ALIASES = {
"DB Regio AG NRW": OPERATORS["db_allgemein"], "DB Regio AG NRW": OPERATORS["db_allgemein"],
"DB Regio AG Südost": OPERATORS["db_allgemein"], "DB Regio AG Südost": OPERATORS["db_allgemein"],
"DB Regio AG Nordost": OPERATORS["db_allgemein"], "DB Regio AG Nordost": OPERATORS["db_allgemein"],
"DB Regio AG Nord": OPERATORS["db_allgemein"],
"DB Regio AG Mitte": OPERATORS["db_allgemein"], "DB Regio AG Mitte": OPERATORS["db_allgemein"],
"NS International": OPERATORS["NS"],
"SBB GmbH": OPERATORS["SBB"], "SBB GmbH": OPERATORS["SBB"],
"Schweizerische Bundesbahnen SBB": OPERATORS["SBB"], "Schweizerische Bundesbahnen SBB": OPERATORS["SBB"],
"Dänische Staatsbahnen": OPERATORS["DSB"],
"VR": OPERATORS["Vr"]
} }
+43 -14
View File
@@ -1,12 +1,12 @@
import discord import discord
import random import random
from src.utils import convert_iso_string, get_operator_metadata from src.utils import get_operator_metadata, get_next_station, format_via_list, format_stop_list
from src.dc.helpers import format_timestamp_to_dc from src.dc.helpers import format_timestamp_to_dc
def build_embed_footer(mode: str, slogans): def build_embed_footer(mode: str, slogans):
footer_notice = f"Data provided by https://transitous.org • Typ: {mode}" footer_notice = f"Data provided by https://transitous.org • mode: {mode}"
icon = "https://avatars.githubusercontent.com/u/24960008?s=60&v=4" icon = "https://raw.githubusercontent.com/kaaninchen/Gleiswechsel/refs/heads/main/src/data/assets/transitous-logo.png"
if slogans is not None: if slogans is not None:
footer_text = f"{random.choice(slogans)}{footer_notice}" footer_text = f"{random.choice(slogans)}{footer_notice}"
@@ -25,19 +25,31 @@ def build_info_embed() -> discord.Embed:
metadata = get_operator_metadata(agency, trip["route_color"]) metadata = get_operator_metadata(agency, trip["route_color"])
departure = format_timestamp_to_dc(trip["departure"]) departure = format_timestamp_to_dc(trip["departure"])
arrival = format_timestamp_to_dc(trip["arrival"]) arrival = format_timestamp_to_dc(trip["arrival"])
embed = discord.Embed( embed = discord.Embed(
title = trip["long_name"], title = trip["long_name"],
description=f"Abfahrt von {trip["from"]} um {departure}. Ankunft um {arrival}", description=f"Abfahrt von {trip["station"]} um {departure}. Ankunft um {arrival}",
color = metadata["color"] color = metadata["color"]
) )
route_lines = [] stops = trip["stops"]
for stop_name, stop_arrival in trip["stops"].items():
if stop_name == trip["from"]: next_stop = get_next_station(trip["stops"])
route_lines.append(f"**• {stop_name} ({stop_arrival} Uhr)**") print(f"next_stop: {next_stop}")
else: next_stop_station = None
route_lines.append(f"{stop_name} ({stop_arrival} Uhr)") if next_stop:
embed.add_field(name="Route", value="\n".join(route_lines)) 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"]) footer = build_embed_footer(trip["mode"], metadata["slogans"])
embed.set_footer(text=footer["text"], icon_url=footer["icon"]) embed.set_footer(text=footer["text"], icon_url=footer["icon"])
@@ -45,6 +57,23 @@ def build_info_embed() -> discord.Embed:
embed.set_author(name=agency) embed.set_author(name=agency)
embed.set_thumbnail(url=metadata["logo"]) embed.set_thumbnail(url=metadata["logo"])
color = trip.get("color", "keine farbe :(") return embed
print(color)
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
+77 -8
View File
@@ -1,8 +1,10 @@
import discord import discord
import asyncio import asyncio
import random
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from src.utils import logger, channel_formatting, choose_connection from src.utils import logger, channel_formatting, choose_connection, get_sound_path
from src.config import config
_scheduled_task: asyncio.Task | None = None _scheduled_task: asyncio.Task | None = None
@@ -11,24 +13,39 @@ async def rename_vc(bot: discord.Bot, voice_channel, from_scheduler: bool = Fals
if not from_scheduler and _scheduled_task and not _scheduled_task.done(): if not from_scheduler and _scheduled_task and not _scheduled_task.done():
_scheduled_task.cancel() _scheduled_task.cancel()
attempt = 0
max_attempt = 15
trip = choose_connection() 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"] arrival = trip["arrival"]
long_name = trip["long_name"] long_name = trip["long_name"]
mode = trip["mode"]
print("-----------------") print("-----------------")
logger(f"Umstieg: {long_name}, Ankunft: {arrival} Uhr") logger(f"Umstieg: {long_name}; Ankunft: {arrival} Uhr")
logger(f"Betreiber: {trip["agency"]}, Typ: {trip["mode"]}") logger(f"Betreiber: {trip["agency"]}, Typ: {mode}")
logger(f"Versuche Namen zu ändern, wenn nichts passiert bin ich im cooldown... (warte bis zu 10min!)") logger(f"Versuche Namen zu ändern, wenn nichts passiert bin ich im cooldown... (warte bis zu 10min!)")
formatting = channel_formatting(trip["mode"]) formatting = channel_formatting(mode)
await voice_channel.edit(name=f"{formatting}{long_name}") await voice_channel.edit(name=f"{formatting}{long_name}")
await voice_channel.set_status(f"Ankunft um {arrival}") await voice_channel.set_status(f"Ankunft um {arrival}")
logger(f"Name geändert!") logger(f"Name geändert!")
_scheduled_task = asyncio.create_task(_schedule_next_transfer(bot, arrival)) await announcer("umstieg", voice_channel)
async def _schedule_next_transfer(bot, arrival): _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() now = datetime.now()
parsed_time = datetime.strptime(arrival, "%H:%M").time() parsed_time = datetime.strptime(arrival, "%H:%M").time()
arrival_dt = datetime.combine(date.today(), parsed_time) arrival_dt = datetime.combine(date.today(), parsed_time)
@@ -37,12 +54,64 @@ async def _schedule_next_transfer(bot, arrival):
arrival_dt += timedelta(days=1) arrival_dt += timedelta(days=1)
wait_seconds = (arrival_dt - now).total_seconds() wait_seconds = (arrival_dt - now).total_seconds()
announcement_countdown = random.randrange(180, 300)
announcement_countdown = 3
if wait_seconds > 0: if wait_seconds > 0:
remaining = str(timedelta(seconds=wait_seconds)) remaining = str(timedelta(seconds=wait_seconds))
logger(f"Nächster Umstieg in {remaining.split('.')[0]} ({arrival} Uhr)") logger(f"Nächster Umstieg in {remaining.split('.')[0]} ({arrival} Uhr)")
await asyncio.sleep(wait_seconds) 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") logger("Zug angekommen, wähle neue Verbindung")
await rename_vc(bot, from_scheduler=True) 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)
+3 -2
View File
@@ -1,14 +1,15 @@
import discord import discord
from datetime import datetime, timedelta from datetime import datetime, timedelta
from src.utils import logger, convert_iso_string from src.utils import logger
def validate_channel(bot: discord.bot, server_id: int, channel_id: int): def validate_channel(bot: discord.bot, server_id: int, channel_id: int):
guild = bot.get_guild(server_id) guild = bot.get_guild(server_id)
channel = guild.get_channel(channel_id)
if guild is None: if guild is None:
logger(f"Es konnte kein Server mit der ID {server_id} gefunden werden", "fatal") logger(f"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): if not isinstance(channel, discord.VoiceChannel):
logger(f"Es konnte kein VC mit der id {channel_id} gefunden werden", "fatal") logger(f"Es konnte kein VC mit der id {channel_id} gefunden werden", "fatal")
return False return False
+126 -18
View File
@@ -1,7 +1,9 @@
import json
import os import os
import importlib import importlib
from datetime import datetime, timedelta import random
from pathlib import Path
from datetime import datetime, timedelta, timezone
from src.config import config
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import src.data.operators as operators import src.data.operators as operators
@@ -9,8 +11,6 @@ from src.data.emojis import emoji_list
_operator_mtime = None _operator_mtime = None
with open("config.json", "r") as file:
config = json.load(file)
def logger(msg, log_type="info") -> str: def logger(msg, log_type="info") -> str:
status = log_type.upper() status = log_type.upper()
@@ -19,28 +19,60 @@ def logger(msg, log_type="info") -> str:
if status == "FATAL": if status == "FATAL":
os._exit(1) os._exit(1)
def choose_connection() -> dict: def choose_connection() -> dict | None:
from src.api import transitous from src.api import transitous
station_id = transitous.get_random_stop_id() station_id = transitous.get_random_stop_id()
connection = transitous.get_random_connection(station_id) connection = transitous.get_random_connection(station_id)
trip = transitous.get_trip_details(connection["trip_id"], connection["from_station"]) trip = transitous.get_trip_details(connection)
return trip 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: def convert_iso_string(isostring) -> str:
timezone = config.get("timezone", "Europe/Berlin") timezone = config.connections.timezone
dt = datetime.fromisoformat(isostring.replace('Z', '+00:00')) dt = datetime.fromisoformat(isostring.replace('Z', '+00:00'))
dt = dt.astimezone(ZoneInfo(timezone)) dt = dt.astimezone(ZoneInfo(timezone))
if dt.second >= 30: if dt.second >= 30:
dt += timedelta(minutes=1) dt += timedelta(minutes=1)
return dt.strftime('%H:%M') return dt.strftime('%H:%M')
def channel_formatting(mode: str) -> str: def channel_formatting(mode: str) -> str:
formatting = config.get("formatting", "") formatting = config.discord.formatting
if config.get("emojis", True): if config.discord.formatting:
emoji = emoji_list.get(mode) emoji = emoji_list.get(mode)
if emoji is None: if emoji is None:
emoji = emoji_list.get("Fallback") emoji = emoji_list.get("Fallback")
@@ -48,7 +80,7 @@ def channel_formatting(mode: str) -> str:
return f"{emoji}{formatting}" return f"{emoji}{formatting}"
def get_train_name(train_name: str, mode: str) -> str: def get_train_name(train_name: str, mode: str) -> str:
if train_name.isdigit(): if mode == "BUS" or mode == "TRAM":
train = f"{mode.capitalize()} {train_name}" train = f"{mode.capitalize()} {train_name}"
elif "(" in train_name: elif "(" in train_name:
train = train_name.split(" (")[0] train = train_name.split(" (")[0]
@@ -75,24 +107,100 @@ def _reload_operators_if_changed():
def get_operator_metadata(agency: str, route_color: str) -> dict: def get_operator_metadata(agency: str, route_color: str) -> dict:
_reload_operators_if_changed() _reload_operators_if_changed()
op_data = operators.OPERATOR_ALIASES.get(agency) or operators.OPERATORS.get(agency) or operators.OPERATORS["fallback"] op_data = operators.OPERATOR_ALIASES.get(agency) or operators.OPERATORS.get(agency) or operators.OPERATORS["fallback"]
logo = op_data.get("logo", operators.OPERATORS["fallback"]["logo"]) logo = op_data.get("logo", operators.OPERATORS["fallback"]["logo"])
slogans = op_data.get("slogans") slogans = op_data.get("slogan")
color = op_data.get("color") color = op_data.get("color")
if color is None: if color is None or color == operators.OPERATORS["fallback"]["color"]:
if route_color is not None: if route_color is not None:
try: try:
color = int(f"0x{route_color.upper()}") color = int(route_color, 16)
except ValueError: except ValueError:
color = operators.OPERATORS["fallback"]["color"] color = operators.OPERATORS["fallback"]["color"]
else:
logger(f"Managed to get color from API, add agency to src/data/operators.py if you don't like it")
else: else:
color = operators.OPERATORS["fallback"]["colors"] color = operators.OPERATORS["fallback"]["color"]
return { return {
"logo": logo, "logo": logo,
"color": color, "color": color,
"slogans": slogans "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