package Travelynx::Helper::Traewelling; # Copyright (C) 2020-2023 Birte Kristina Friesel # Copyright (C) 2023 networkException <git@nwex.de> # # SPDX-License-Identifier: AGPL-3.0-or-later use strict; use warnings; use 5.020; use utf8; use DateTime; use DateTime::Format::Strptime; use Mojo::Promise; sub new { my ( $class, %opt ) = @_; my $version = $opt{version}; $opt{header} = { 'User-Agent' => "travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx", 'Accept' => 'application/json', }; $opt{strp1} = DateTime::Format::Strptime->new( pattern => '%Y-%m-%dT%H:%M:%S.000000Z', time_zone => 'UTC', ); $opt{strp2} = DateTime::Format::Strptime->new( pattern => '%Y-%m-%d %H:%M:%S', time_zone => 'Europe/Berlin', ); $opt{strp3} = DateTime::Format::Strptime->new( pattern => '%Y-%m-%dT%H:%M:%S%z', time_zone => 'Europe/Berlin', ); return bless( \%opt, $class ); } sub epoch_to_dt_or_undef { my ($epoch) = @_; if ( not $epoch ) { return undef; } return DateTime->from_epoch( epoch => $epoch, time_zone => 'Europe/Berlin', locale => 'de-DE', ); } sub parse_datetime { my ( $self, $dt ) = @_; return $self->{strp1}->parse_datetime($dt) // $self->{strp2}->parse_datetime($dt) // $self->{strp3}->parse_datetime($dt); } sub get_status_p { my ( $self, %opt ) = @_; my $username = $opt{username}; my $token = $opt{token}; my $promise = Mojo::Promise->new; my $header = { 'User-Agent' => $self->{header}{'User-Agent'}, 'Accept' => 'application/json', 'Authorization' => "Bearer $token", }; $self->{user_agent}->request_timeout(20) ->get_p( "https://traewelling.de/api/v1/user/${username}/statuses?limit=1" => $header )->then( sub { my ($tx) = @_; if ( my $err = $tx->error ) { my $err_msg = "v1/user/${username}/statuses: HTTP $err->{code} $err->{message}"; $promise->reject( { http => $err->{code}, text => $err_msg } ); return; } else { if ( my $status = $tx->result->json->{data}[0] ) { my $status_id = $status->{id}; my $message = $status->{body}; my $checkin_at = $self->parse_datetime( $status->{createdAt} ); my $dep_dt = $self->parse_datetime( $status->{train}{origin}{departurePlanned} ); my $arr_dt = $self->parse_datetime( $status->{train}{destination}{arrivalPlanned} ); my $dep_eva = $status->{train}{origin}{evaIdentifier}; my $arr_eva = $status->{train}{destination}{evaIdentifier}; my $dep_ds100 = $status->{train}{origin}{rilIdentifier}; my $arr_ds100 = $status->{train}{destination}{rilIdentifier}; my $dep_name = $status->{train}{origin}{name}; my $arr_name = $status->{train}{destination}{name}; my $category = $status->{train}{category}; my $linename = $status->{train}{lineName}; my $train_no = $status->{train}{journeyNumber}; my $trip_id = $status->{train}{hafasId}; my ( $train_type, $train_line ) = split( qr{ }, $linename ); $promise->resolve( { http => $tx->res->code, status_id => $status_id, message => $message, checkin => $checkin_at, dep_dt => $dep_dt, dep_eva => $dep_eva, dep_ds100 => $dep_ds100, dep_name => $dep_name, arr_dt => $arr_dt, arr_eva => $arr_eva, arr_ds100 => $arr_ds100, arr_name => $arr_name, trip_id => $trip_id, train_no => $train_no, train_type => $train_type, line => $linename, line_no => $train_line, category => $category, } ); return; } else { $promise->reject( { text => "v1/${username}/statuses: unknown error" } ); return; } } } )->catch( sub { my ($err) = @_; $promise->reject( { text => "v1/${username}/statuses: $err" } ); return; } )->wait; return $promise; } sub get_user_p { my ( $self, $uid, $token ) = @_; my $ua = $self->{user_agent}->request_timeout(20); my $header = { 'User-Agent' => $self->{header}{'User-Agent'}, 'Accept' => 'application/json', 'Authorization' => "Bearer $token", }; my $promise = Mojo::Promise->new; $ua->get_p( "https://traewelling.de/api/v1/auth/user" => $header )->then( sub { my ($tx) = @_; if ( my $err = $tx->error ) { my $err_msg = "v1/auth/user: HTTP $err->{code} $err->{message}"; $promise->reject($err_msg); return; } else { my $user_data = $tx->result->json->{data}; $self->{model}->set_user( uid => $uid, trwl_id => $user_data->{id}, screen_name => $user_data->{displayName}, user_name => $user_data->{username}, ); $promise->resolve; return; } } )->catch( sub { my ($err) = @_; $promise->reject("v1/auth/user: $err"); return; } )->wait; return $promise; } sub logout_p { my ( $self, %opt ) = @_; my $uid = $opt{uid}; my $token = $opt{token}; my $ua = $self->{user_agent}->request_timeout(20); my $header = { 'User-Agent' => $self->{header}{'User-Agent'}, 'Accept' => 'application/json', 'Authorization' => "Bearer $token", }; my $request = {}; $self->{model}->unlink( uid => $uid ); my $promise = Mojo::Promise->new; $ua->post_p( "https://traewelling.de/api/v1/auth/logout" => $header => json => $request )->then( sub { my ($tx) = @_; if ( my $err = $tx->error ) { my $err_msg = "v1/auth/logout: HTTP $err->{code} $err->{message}"; $promise->reject($err_msg); return; } else { $promise->resolve; return; } } )->catch( sub { my ($err) = @_; $promise->reject("v1/auth/logout: $err"); return; } )->wait; return $promise; } sub convert_travelynx_to_traewelling_visibility { my ($travelynx_visibility) = @_; my %visibilities = ( # public => StatusVisibility::PUBLIC 100 => 0, # travelynx => StatusVisibility::AUTHENTICATED # (only visible for logged in users) 80 => 4, # followers => StatusVisibility::FOLLOWERS 60 => 2, # unlisted => StatusVisibility::PRIVATE # (there is no träwelling equivalent to unlisted, their # StatusVisibility::UNLISTED shows the journey on the profile) 30 => 3, # private => StatusVisibility::PRIVATE 10 => 3, ); return $visibilities{$travelynx_visibility}; } sub checkin_p { my ( $self, %opt ) = @_; my $header = { 'User-Agent' => $self->{header}{'User-Agent'}, 'Accept' => 'application/json', 'Authorization' => "Bearer $opt{token}", }; my $departure_ts = epoch_to_dt_or_undef( $opt{dep_ts} ); my $arrival_ts = epoch_to_dt_or_undef( $opt{arr_ts} ); if ($departure_ts) { $departure_ts = $departure_ts->rfc3339; } if ($arrival_ts) { $arrival_ts = $arrival_ts->rfc3339; } my $request = { tripId => $opt{trip_id}, lineName => $opt{train_type} . ' ' . ( $opt{train_line} // $opt{train_no} ), ibnr => \1, start => q{} . $opt{dep_eva}, destination => q{} . $opt{arr_eva}, departure => $departure_ts, arrival => $arrival_ts, toot => $opt{data}{toot} ? \1 : \0, tweet => $opt{data}{tweet} ? \1 : \0, visibility => convert_travelynx_to_traewelling_visibility( $opt{visibility} ) }; if ( $opt{user_data}{comment} ) { $request->{body} = $opt{user_data}{comment}; } my $debug_prefix = "v1/trains/checkin('$request->{lineName}' $request->{tripId} $request->{start} -> $request->{destination})"; my $promise = Mojo::Promise->new; $self->{user_agent}->request_timeout(20) ->post_p( "https://traewelling.de/api/v1/trains/checkin" => $header => json => $request )->then( sub { my ($tx) = @_; if ( my $err = $tx->error ) { my $err_msg = "HTTP $err->{code} $err->{message}"; if ( $tx->res->body ) { if ( $err->{code} == 409 ) { my $j = $tx->res->json; $err_msg .= sprintf( ': Bereits in %s eingecheckt: https://traewelling.de/status/%d', $j->{message}{lineName}, $j->{message}{status_id} ); } else { $err_msg .= ' ' . $tx->res->body; } } $self->{log} ->debug("Traewelling $debug_prefix error: $err_msg"); $self->{model}->log( uid => $opt{uid}, message => "Konnte $opt{train_type} $opt{train_no} nicht übertragen: $debug_prefix returned $err_msg", is_error => 1 ); $promise->reject( { http => $err->{code} } ); return; } $self->{log}->debug( "... success! " . $tx->res->body ); $self->{model}->log( uid => $opt{uid}, message => "Eingecheckt in $opt{train_type} $opt{train_no}", status_id => $tx->res->json->{statusId} ); $self->{model}->set_latest_push_ts( uid => $opt{uid}, ts => $opt{checkin_ts} ); $promise->resolve( { http => $tx->res->code } ); # TODO store status_id in in_transit object so that it can be shown # on the user status page return; } )->catch( sub { my ($err) = @_; $self->{log}->debug("... $debug_prefix error: $err"); $self->{model}->log( uid => $opt{uid}, message => "Konnte $opt{train_type} $opt{train_no} nicht übertragen: $debug_prefix returned $err", is_error => 1 ); $promise->reject( { connection => $err } ); return; } )->wait; return $promise; } 1;