diff --git a/Cargo.lock b/Cargo.lock index 3f9dc02..f79e913 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,6 +159,16 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "chrono-tz" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2554a3155fec064362507487171dcc4edc3df60cb10f3a1fb10ed8094822b120" +dependencies = [ + "chrono", + "parse-zoneinfo", +] + [[package]] name = "cipher" version = "0.2.5" @@ -693,15 +703,17 @@ checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" [[package]] name = "joel-bot" -version = "1.0.2" +version = "1.0.5" dependencies = [ "chrono", + "chrono-tz", "clokwerk", "rand 0.7.3", "reqwest", "rocket", "rocket_contrib", "serde", + "serde_json", "serde_yaml", ] @@ -973,6 +985,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + [[package]] name = "pear" version = "0.1.4" @@ -1194,6 +1215,21 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + [[package]] name = "remove_dir_all" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 7ea295d..da8d7be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "joel-bot" -version = "1.0.2" +version = "1.0.5" authors = [ "Fabian Eriksson ", "Joakim Anell ", @@ -18,8 +18,10 @@ path = "src/lib.rs" reqwest = { version = "0.10.9", features = ["blocking", "json"] } serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.8.14" +serde_json = "1.0" rocket = { version = "0.4.6" } rocket_contrib = { version = "0.4.6", default-features = false, features = ["json"] } chrono = "0.4.19" +chrono-tz = "0.5.3" clokwerk = "0.3.3" rand = "0.7.3" diff --git a/config.yaml b/config.yaml index efa4ccf..caaf21d 100644 --- a/config.yaml +++ b/config.yaml @@ -3,9 +3,10 @@ intro: - "Hej hörni! :joel:" - "Tjenis! Är allt väl? :joel:" - "Yo, allt väl hoppas jag? :joel:" - about_me: "Mitt namn är joel-bot och jag har axlat @chikken's arbete nu när han inte längre finns ibland oss!\nJag kommer pliktskyldigt påminna er om att tidsrapportera sista arbetsdagen i månaden och kan även svara på frågor kring tidrapportering.\nNi kan läsa mig här: https://github.com/Pirayya/joel-bot" + about_me: "Mitt namn är joel-bot och jag har axlat @chikken's (Joel Wahlund) arbete nu när han inte längre finns ibland oss!\nJag kommer pliktskyldigt påminna er om att tidsrapportera sista arbetsdagen i månaden och kan även svara på frågor kring tidrapportering.\nNi kan läsa mig här: https://github.com/Pirayya/joel-bot" features: - "/joel - prova! Bara du som ser!" + - "/ta-mig-hem [från, till] - jag kollar på SL åt dig och ger dig tre resförslag, ex: `/ta-mig-hem t-centr fruängen`" - "tid - fråga mig om när ni ska tidsrapportera denna månaden" - "pricing - hur mycket kostar jag, alltså vad skulle det kosta att köra en on-premise joel-bot?" - "skribenter - mina skapare, _i bokstavsordning på efternamn_" diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..ae64778 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,6 @@ +pub mod routes; +mod sl; +mod slack; + +pub use self::sl::*; +pub use self::slack::*; diff --git a/src/api/routes.rs b/src/api/routes.rs new file mode 100644 index 0000000..eec5be3 --- /dev/null +++ b/src/api/routes.rs @@ -0,0 +1,206 @@ +use std::collections::HashMap; +use std::error::Error; +use std::thread; +use std::time::Duration; + +use chrono::Utc; +use rand::{Rng, thread_rng}; +use reqwest::blocking::Client; +use rocket::FromForm; +use rocket::http::Status; +use rocket::post; +use rocket::request::LenientForm; +use rocket::State; +use rocket_contrib::json::Json; +use serde::Serialize; + +use crate::api::{SlackBlockResponse, SlackErrorResponse, SLApi, SLApiKeys, SLStationInfo, SLStationInfoResponse}; +use crate::events::{SlackEvents, SlackRequest}; +use crate::last_day::get_last_workday; + +#[post("/take-me-home", format = "application/x-www-form-urlencoded", data = "")] +pub fn handle_trip_command(state: State, request: LenientForm) -> String { + let stations: Vec<&str> = request.text.split(' ').collect(); + if stations.len() != 2 { + println!("Got too few stations"); + return "Jag behöver två argument fattaru la? Ex: `/gg t-centr fruän`".to_string(); + }; + + let from_name = stations[0].to_string(); + let to_name = stations[1].to_string(); + + let trip_token = state.get_trip_token.clone(); + let stations_token = state.get_stations_token.clone(); + thread::spawn(move || { + let http_client = Client::new(); + + let from_result = SLApi::read_station(&http_client, &stations_token, &from_name, 1); + let to_result = SLApi::read_station(&http_client, &stations_token, &to_name, 1); + + let from_station = match get_first_station(from_result) { + Some(f) => f, + None => { + println!("Could not find a station with name: {}", from_name); + send_json_response(&http_client, &request.response_url, &SlackErrorResponse::new(format!("Hittade ingen station med namnet och {}", &from_name))); + return; + } + }; + + let to_station = match get_first_station(to_result) { + Some(t) => t, + None => { + println!("Could not find a station with name: {}", to_name); + send_json_response(&http_client, &request.response_url, &SlackErrorResponse::new(format!("Hittade ingen station med namnet {}", &to_name))); + return; + } + }; + + let result = SLApi::list_trips(&http_client, &trip_token, &from_station.site_id, &to_station.site_id); + + let result = match result { + Ok(trip_response) => { + SlackBlockResponse::create_trip_response(&from_name, &to_name, &trip_response) + } + Err(error) => { + println!("Error: {}", error); + send_json_response(&http_client, &request.response_url, &SlackErrorResponse::new(format!("Hittade ingen resa mellan {} och {}", &from_name, &to_name))); + return; + } + }; + + send_json_response(&http_client, &request.response_url, &result) + }); + + String::from("Låt mig se efter om det finns en resa hos SL åt dig!") +} + +fn get_first_station(result: Result>) -> Option { + match result { + Ok(to) => { + let data = to.response_data.unwrap(); + match data.get(0) { + Some(t) => Some(t.clone()), + None => { + // TODO: Send error + None + } + } + } + Err(_error) => { + // TODO: Send error + None + } + } +} + +#[post("/slack-request", format = "application/json", data = "")] +pub fn slack_request(state: State, request: Json) -> String { + state.handle_request(request.0) +} + +// More information here: https://api.slack.com/interactivity/slash-commands +#[derive(FromForm)] +pub struct SlackSlashMessage { + // token: String, <-- We should save and validate this + // command: String, <-- can be used to check what command was used. + text: String, + // <-- Seems to exists even if it is empty + response_url: String, +} + +#[post("/time-report", format = "application/x-www-form-urlencoded", data = "")] +pub fn time_report(request: LenientForm) -> String { + let response_url = request.response_url.clone(); + + let calculations = vec!["vänta", "beräknar", "processerar", "finurlar", "gnuggar halvledarna", "tömmer kvicksilver-depå"]; + + thread::spawn(move || { + let now = Utc::now(); + let http_client = Client::new(); + let mut map = HashMap::new(); + + match get_last_workday(&now) { + Ok(last_workday) => { + if last_workday == now.naive_utc().date() { + map.insert("text", format!("Okej, jag har kikat i kalendern och det är först *{}* som du behöver tidrapportera!", last_workday)); + + sleep_and_send_time_report_response(&http_client, &response_url, &map); + + let mut rng = thread_rng(); + for _ in 0..2 { + let pos = rng.gen_range(0, calculations.len() - 1); + + map.insert("text", format!("... {}", calculations[pos])); + + sleep_and_send_time_report_response(&http_client, &response_url, &map); + } + + map.insert("text", String::from("... det är ju idag!")); + + sleep_and_send_time_report_response(&http_client, &response_url, &map); + } else { + map.insert("text", format!("Nu har jag gjort diverse uppslag och scrape:at nätet och det är inte förrän *{}* som du behöver tidrapportera!", last_workday)); + + sleep_and_send_time_report_response(&http_client, &response_url, &map) + } + } + Err(error) => { + println!("failed to get last work day: {}", error); + + map.insert("text", String::from("Misslyckades stenhårt...")); + sleep_and_send_time_report_response(&http_client, &response_url, &map) + } + }; + }); + + format!("Ska ta en titt i kalendern...") +} + +fn sleep_and_send_time_report_response(http_client: &Client, url: &String, map: &HashMap<&str, String>) { + // To "fool" the user that we are actually calculating something + thread::sleep(Duration::from_secs(2)); + + send_response(http_client, url, map) +} + +fn send_response(http_client: &Client, url: &String, map: &HashMap<&str, String>) { + let resp = http_client.post(url.as_str()) + .json(map) + .send(); + + match resp { + Ok(r) => { + if !r.status().is_success() { + println!("failed to send message, {}", r.status().as_str()); + let result = r.text(); + if result.is_ok() { + println!("{}", result.unwrap()); + } + } + } + Err(err) => { + println!("got exception while sending message: {}", err) + } + } +} + +fn send_json_response(http_client: &Client, url: &String, data: &impl Serialize) { + let resp = http_client.post(url.as_str()) + .json(data) + .send(); + + match resp { + Ok(r) => { + if !r.status().is_success() { + println!("failed to send message, {}", r.status().as_str()); + let result = r.text(); + if result.is_ok() { + println!("{}", result.unwrap()); + } + } + } + Err(err) => { + println!("got exception while sending message: {}", err) + } + } +} diff --git a/src/api/sl.rs b/src/api/sl.rs new file mode 100644 index 0000000..7886ff5 --- /dev/null +++ b/src/api/sl.rs @@ -0,0 +1,193 @@ +use std::fmt::{Display, Formatter}; + +use serde::{Serialize, Deserialize}; +use reqwest::blocking::Client; +use std::error::Error; +use std::collections::HashMap; + +#[derive(Deserialize, Debug)] +pub struct SLTripResponse { + #[serde(rename(deserialize = "Trip"))] + pub(crate) trip: Vec, +} + +#[derive(Deserialize, Debug)] +pub struct SLTrip { + #[serde(rename(deserialize = "LegList"))] + pub(crate) leg_list: SLLegList, +} + +#[derive(Deserialize, Debug)] +pub struct SLLegList { + #[serde(rename(deserialize = "Leg"))] + pub(crate) legs: Vec, +} + +#[derive(Deserialize, Debug)] +#[serde(tag = "type")] +pub enum SLLeg { + WALK(SLWalk), + JNY(SLVehicle), +} + +#[derive(Deserialize, Debug)] +pub struct SLVehicle { + #[serde(rename(deserialize = "Origin"))] + pub(crate) origin: SLStation, + #[serde(rename(deserialize = "Destination"))] + pub(crate) destination: SLStation, + pub(crate) name: String, + pub(crate) direction: String, + pub(crate) category: SLCategory, +} + +/// SLWalk doesn't contain category +#[derive(Deserialize, Debug)] +pub struct SLWalk { + #[serde(rename(deserialize = "Origin"))] + pub(crate) origin: SLStation, + #[serde(rename(deserialize = "Destination"))] + pub(crate) destination: SLStation, + pub(crate) name: String, + pub(crate) duration: String, + pub(crate) dist: i32, + pub(crate) hide: Option, // Can be missing... What? +} + +#[derive(Deserialize, Debug)] +pub enum SLCategory { + TRN, + TRM, + BUS, + MET, + UUU, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SLStation { + pub(crate) name: String, + pub(crate) time: String, + // TODO: fix to real time + pub(crate) date: String, + #[serde(rename(deserialize = "rtTime"))] + pub(crate) rt_time: Option, + #[serde(rename(deserialize = "rtDate"))] + pub(crate) rt_date: Option, +} + +impl SLStation { + pub fn get_time(&self) -> String { + match &self.rt_time { + Some(real_time) => real_time.clone(), + None => self.time.clone() + } + } + + pub fn get_date(&self) -> String { + match &self.rt_date { + Some(real_date) => real_date.clone(), + None => self.date.clone() + } + } +} + +// Station info, name and id. +#[derive(Deserialize, Debug)] +pub struct SLStationInfoResponse { + #[serde(rename(deserialize = "StatusCode"))] + pub(crate) status_code: i16, + #[serde(rename(deserialize = "Message"))] + pub(crate) message: Option, + #[serde(rename(deserialize = "ResponseData"))] + pub(crate) response_data: Option>, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct SLStationInfo { + #[serde(rename(deserialize = "Name"))] + pub(crate) name: String, + #[serde(rename(deserialize = "SiteId"))] + pub(crate) site_id: String, +} + +#[derive(Debug, Clone)] +pub struct SLError { + pub(crate) message: String, +} + +impl Display for SLError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Error: {}", self.message) + } +} + +impl std::error::Error for SLError {} + +impl SLError { + pub fn new(message: String) -> SLError { + SLError { + message + } + } +} + +pub struct SLApiKeys { + pub get_trip_token: String, + pub get_stations_token: String, +} + +impl SLApiKeys { + pub fn new() -> Result> { + let get_trip_token = std::env::var("SL_TRIP_API_TOKEN")?; + let get_stations_token = std::env::var("SL_STATION_LIST_API_TOKEN")?; + + Ok(SLApiKeys { + get_trip_token, + get_stations_token + }) + } +} + +pub struct SLApi {} + +impl SLApi { + pub fn list_trips(http_client: &Client, trip_token: &String, from: &str, to: &str) -> Result> { + let mut query = HashMap::new(); + query.insert("key", trip_token.as_str()); + query.insert("originId", from); + query.insert("destId", to); + + let sl_resp: SLTripResponse = http_client.get("https://api.sl.se/api2/TravelplannerV3_1/trip.json") + .query(&query) + .send()? + .json()?; + + Ok(sl_resp) + } + + /// If the Result is Ok, then the response_data contains Some. + pub fn read_station(http_client: &Client, station_token: &String, name: &str, max_result: i32) -> Result> { + let mut query = HashMap::new(); + query.insert("key", station_token.as_str()); // <-- API Key + query.insert("searchstring", name); + query.insert("stationsonly", "false"); + + let max_result_str = max_result.to_string(); + query.insert("maxresults", max_result_str.as_str()); + + let sl_resp: SLStationInfoResponse = http_client.get("https://api.sl.se/api2/typeahead.json") + .query(&query) + .send()? + .json()?; + + match sl_resp.status_code { + 0 => { + if sl_resp.response_data.is_some() { + return Ok(sl_resp); + } + Err(SLError::new("got empty response data".to_string()).into()) + } + _ => Err(SLError::new(sl_resp.message.unwrap()).into()) + } + } +} \ No newline at end of file diff --git a/src/api/slack.rs b/src/api/slack.rs new file mode 100644 index 0000000..da0e6e6 --- /dev/null +++ b/src/api/slack.rs @@ -0,0 +1,383 @@ +use serde::{Serialize}; +use crate::api::{SLWalk, SLVehicle, SLCategory, SLStation, SLLeg, SLTrip, SLTripResponse}; +use chrono::{NaiveDateTime, DateTime, Utc}; + +use chrono_tz::Europe::Stockholm; +use chrono_tz::{Tz, OffsetComponents}; + +const MAX_TRIPS: usize = 3; + +#[derive(Serialize, Debug)] +pub struct SlackBlockResponse { + pub(crate) blocks: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) response_type: Option, +} + +impl SlackBlockResponse { + pub fn create_trip_response(from: &str, to: &str, trip_response: &SLTripResponse) -> SlackBlockResponse { + let mut blocks = Vec::new(); + blocks.push(SlackBlock { + text: Some(SlackText { + text_type: "mrkdwn".to_string(), + text: format!("*Sökning:* {} _till_ {}", from, to), + }), + block_type: "section".to_string(), + ..Default::default() + }); + blocks.push(SlackBlock { + elements: Some(vec!( + SlackPlaceholder { + placeholder_type: "mrkdwn".to_string(), + text: ":warning: *Tänk på att vissa byten kan innehålla förseningar*".to_string(), + emoji: None, + } + )), + block_type: "context".to_string(), + ..Default::default() + }); + let mut trips = trip_response.trip.iter() + .take(MAX_TRIPS) + .map(|trip| { + let mut blocks = Vec::new(); + blocks.push(SlackBlock { + block_type: "divider".to_string(), + ..Default::default() + }); + blocks.append(&mut SlackBlock::create_trip_block(trip)); + blocks.push(SlackBlock::total_travel_time(trip)); + return blocks; + }) + .flatten() + .collect::>(); + + blocks.append(&mut trips); + + SlackBlockResponse { + blocks, + response_type: Some(String::from("ephemeral")), + } + } +} + +#[derive(Serialize, Debug)] +pub struct SlackBlock { + #[serde(rename(serialize = "type"))] // section + pub(crate) block_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) accessory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) elements: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) fields: Option>, +} + +impl Default for SlackBlock { + fn default() -> Self { + SlackBlock { + block_type: "section".to_string(), + text: None, + accessory: None, + elements: None, + fields: None, + } + } +} + +impl SlackBlock { + pub fn total_travel_time(trip: &SLTrip) -> SlackBlock { + // We know that the trip will have at least one leg element, so we can unwrap both + let first = trip.leg_list.legs.first() + .unwrap(); + let last = trip.leg_list.legs.last() + .unwrap(); + + let origin = match first { + SLLeg::JNY(vehicle) => parse_date_from_string(&vehicle.origin), + SLLeg::WALK(walk) => parse_date_from_string(&walk.origin), + }; + + let dest = match last { + SLLeg::JNY(vehicle) => parse_date_from_string(&vehicle.destination), + SLLeg::WALK(walk) => parse_date_from_string(&walk.destination), + }; + + let start = origin.format("%R"); // Only time + let stop = dest.format("%R"); // Only time + + let dest_backup = dest.clone(); + + let now: DateTime = Utc::now().with_timezone(&Stockholm); + + let duration = dest.signed_duration_since(origin); + let duration_to_final_destination = dest_backup.signed_duration_since(now); + + SlackBlock { + block_type: "context".to_string(), + elements: Some(vec!( + SlackPlaceholder { + text: format!( + "*Start*: {} - *Framme*: {} - *Total restid*: {}\nTar du denna resa är du framme om ungefär {}", + start, + stop, + generate_formatted_duration(&duration), + generate_formatted_duration(&duration_to_final_destination) + ), + placeholder_type: "mrkdwn".to_string(), + emoji: None, + } + )), + ..Default::default() + } + } + + pub fn create_trip_block(trip: &SLTrip) -> Vec { + trip.leg_list.legs.iter() + .filter(|leg| { + match leg { + SLLeg::WALK(walk) => walk.hide.contains(&false), // Skip all hidden walks + SLLeg::JNY(_) => true + } + }) + .enumerate() + .map(|(i, leg)| { + match leg { + SLLeg::WALK(walk) => { + if walk.hide.contains(&true) {} + SlackBlock::from_walk(i, walk) + } + SLLeg::JNY(vehicle) => { + SlackBlock::from_vehicle(i, vehicle) + } + } + }) + .flatten() + .collect::>() + } + + pub fn from_walk(i: usize, walk: &SLWalk) -> Vec { + vec![ + SlackBlock { + block_type: "section".to_string(), + text: Some(SlackText { + text_type: "mrkdwn".to_string(), + text: format!("{} Gå :walking:", get_slack_emoji_from_number(i + 1)), + }), + ..Default::default() + }, + SlackBlock { + block_type: "section".to_string(), + fields: Some(vec![ + SlackText { + text_type: "mrkdwn".to_string(), + text: format!("*Från*\n{}", walk.origin.name), + }, + SlackText { + text_type: "mrkdwn".to_string(), + text: format!("*Till*\n{}", walk.destination.name), + }, + SlackText { + text_type: "mrkdwn".to_string(), + text: format!("*Tid att gå:*\n{}", walk.duration), // Format to minutes and seconds + }, + SlackText { + text_type: "mrkdwn".to_string(), + text: format!("*Avstånd:*\n{} meter", walk.dist), + }, + ]), + ..Default::default() + }, + ] + } + + pub fn from_vehicle(i: usize, vehicle: &SLVehicle) -> Vec { + vec![ + SlackBlock { + block_type: "section".to_string(), + text: Some(SlackText { + text_type: "mrkdwn".to_string(), + text: format!("{} *{}* mot *{}* {}", + get_slack_emoji_from_number(i + 1), + some_kind_of_uppercase_first_letter(vehicle.name.as_str()), + vehicle.direction, + match vehicle.category { + SLCategory::MET => String::from(":metro:"), + SLCategory::BUS => String::from(":bus:"), + SLCategory::TRN => String::from(":bullettrain_front:"), + SLCategory::TRM => String::from(":tram:"), + SLCategory::UUU => String::from(":thonking:") + } + ), + }), + ..Default::default() + }, + SlackBlock { + block_type: "section".to_string(), + fields: Some(vec![ + SlackText { + text_type: "mrkdwn".to_string(), + text: format!("*Från*\n{}", vehicle.origin.name), + }, + SlackText { + text_type: "mrkdwn".to_string(), + text: format!("*Till*\n{}", vehicle.destination.name), + }, + SlackText { + text_type: "mrkdwn".to_string(), + text: format!("*Avgår (preliminärt):*\n{}", generate_timestamps_from_vehicle(&vehicle.origin)), // Format to minutes and seconds + }, + SlackText { + text_type: "mrkdwn".to_string(), + text: format!("*Framme (preliminärt):*\n{}", generate_timestamps_from_vehicle(&vehicle.destination)), + }, + ]), + ..Default::default() + }, + ] + } +} + +fn generate_timestamps_from_vehicle(vehicle_point: &SLStation) -> String { + let time = vehicle_point.get_time(); + let date = vehicle_point.get_date(); + + // TODO: Handle late metros + match NaiveDateTime::parse_from_str( + format!("{} {}", date, time).as_str(), + "%Y-%m-%d %H:%M:%S", + ) { + Ok(time) => { + format!("", time.timestamp(), time.format("%R"), time.format("%F %R")) + }, + Err(_err) => format!("{} {}", vehicle_point.date, vehicle_point.time) + } +} + +fn get_slack_emoji_from_number(i: usize) -> String { + match i { + 1 => String::from(":one:"), + 2 => String::from(":two:"), + 3 => String::from(":three:"), + 4 => String::from(":four:"), + 5 => String::from(":five:"), + 6 => String::from(":six:"), + 7 => String::from(":seven:"), + 8 => String::from(":eight:"), + 9 => String::from(":nine:"), + _ => String::from(":1234:") // Unknown value + } +} + +fn parse_date_from_string(vehicle_point: &SLStation) -> DateTime { + let now = Utc::now().with_timezone(&Stockholm); + let offset_hours = now.offset().base_utc_offset().num_hours() + now.offset().dst_offset().num_hours(); + + match DateTime::parse_from_str( + format!("{} {} +0{}00", vehicle_point.get_date(), vehicle_point.get_time(), offset_hours).as_str(), + "%Y-%m-%d %H:%M:%S %z", + ) { + Ok(time) => time.with_timezone(&Stockholm), + Err(_err) => Utc::now().with_timezone(&Stockholm) + } +} + +fn generate_formatted_duration(duration: &chrono::Duration) -> String { + let minutes = duration.num_minutes() % 60; + let hours = duration.num_hours(); + let hours = match hours { + 1 => format!("{} timme", hours), + hours if hours > 1 => format!("{} timmar", hours), + _ => String::from("") + }; + + let minutes = match minutes { + 1 => format!("{} minut", minutes), + minutes if minutes > 1 => format!("{} minuter", minutes), + _ => String::from("N/A") + }; + + match hours.len() { + 0 => minutes, + _ => format!("{} och {}", hours, minutes) + } +} + +// Stole it from Stack overflow: https://stackoverflow.com/questions/38406793/why-is-capitalizing-the-first-letter-of-a-string-so-convoluted-in-rust +fn some_kind_of_uppercase_first_letter(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +/// text cannot be empty, the API will return error status for an empty String. +#[derive(Serialize, Debug)] +pub struct SlackText { + #[serde(rename(serialize = "type"))] + pub(crate) text_type: String, + // mrkdwn or plain_text + pub(crate) text: String, +} + +#[derive(Serialize, Debug)] +pub struct SlackAccessory { + #[serde(rename(serialize = "type"))] // static_select + pub(crate) accessory_type: String, + pub(crate) placeholder: Option, + pub(crate) options: Vec, + pub(crate) action_id: String, +} + +impl Default for SlackAccessory { + fn default() -> Self { + SlackAccessory { + accessory_type: "static_select".to_string(), + placeholder: None, + options: vec![], + action_id: "".to_string(), + } + } +} + +#[derive(Serialize, Debug)] +pub struct SlackOption { + pub(crate) text: SlackPlaceholder, + pub(crate) value: String, +} + +#[derive(Serialize, Debug)] +pub struct SlackPlaceholder { + #[serde(rename(serialize = "type"))] // plain_text + pub(crate) placeholder_type: String, + pub(crate) text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) emoji: Option, +} + +impl Default for SlackPlaceholder { + fn default() -> Self { + SlackPlaceholder { + placeholder_type: "plain_text".to_string(), + text: "".to_string(), + emoji: None, + } + } +} + +#[derive(Serialize, Debug)] +pub struct SlackErrorResponse { + text: String, + response_type: String, +} + +impl SlackErrorResponse { + pub fn new(message: String) -> SlackErrorResponse { + SlackErrorResponse { + text: message, + response_type: String::from("ephemeral"), + } + } +} diff --git a/src/main.rs b/src/main.rs index eb9b925..6c2948a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,24 @@ -#![feature(proc_macro_hygiene, decl_macro)] +#![feature(proc_macro_hygiene, decl_macro, option_result_contains)] #[macro_use] extern crate rocket; +extern crate chrono; +extern crate chrono_tz; use std::time::Duration; use chrono::{Datelike, NaiveTime, Utc}; use clokwerk::Interval::Weekday; use clokwerk::Scheduler; -use rocket_contrib::json::Json; -use crate::config::*; -use crate::last_day::{get_last_workday, is_last_workday}; use slack::client::*; use slack::events; -use slack::events::{SlackRequest, SlackEvents}; -use rocket::State; -use rocket::request::LenientForm; -use std::thread; -use reqwest::blocking::Client; -use std::collections::HashMap; -use std::error::Error; -use rand::{thread_rng, Rng}; + +use crate::api::{routes, SLApiKeys}; +use crate::config::*; +use crate::last_day::{get_last_workday, is_last_workday}; + +mod api; mod last_day; mod config; @@ -32,6 +29,9 @@ fn main() { let client = SlackClient::new() .expect("couldn't initiate slack client"); + let sl_api_keys = SLApiKeys::new() + .expect("sl api keys missing"); + // Run scheduler let mut scheduler = Scheduler::with_tz(chrono::Utc); scheduler.every(Weekday) @@ -67,97 +67,16 @@ fn main() { // Start web server rocket::ignite() - .mount("/", routes![slack_request, time_report]) + .mount("/", routes![ + routes::slack_request, + routes::time_report, + routes::handle_trip_command + ]) .manage(slack_events) + .manage(sl_api_keys) .launch(); } -#[post("/slack-request", format = "application/json", data = "")] -fn slack_request(state: State, request: Json) -> String { - state.handle_request(request.0) -} - -// More information here: https://api.slack.com/interactivity/slash-commands -#[derive(FromForm)] -struct SlackSlashMessage { - // token: String, <-- We should save and validate this - // command: String, <-- can be used to check what command was used. - text: Option, - response_url: String, -} - -#[post("/time-report", format = "application/x-www-form-urlencoded", data = "")] -fn time_report(request: LenientForm) -> String { - let response_url = request.response_url.clone(); - - let calculations = vec!["vänta", "beräknar", "processerar", "finurlar", "gnuggar halvledarna", "tömmer kvicksilver-depå"]; - - thread::spawn(move || { - let now = Utc::now(); - let http_client = Client::new(); - let mut map = HashMap::new(); - - match get_last_workday(&now) { - Ok(last_workday) => { - if last_workday == now.naive_utc().date() { - map.insert("text", format!("Okej, jag har kikat i kalendern och det är först *{}* som du behöver tidrapportera!", last_workday)); - - sleep_and_send_time_report_response(&http_client, &response_url, &map); - - let mut rng = thread_rng(); - for _ in 0..2 { - let pos = rng.gen_range(0, calculations.len() - 1); - - map.insert("text", format!("... {}", calculations[pos])); - - sleep_and_send_time_report_response(&http_client, &response_url, &map); - } - - map.insert("text", String::from("... det är ju idag!")); - - sleep_and_send_time_report_response(&http_client, &response_url, &map); - } else { - map.insert("text", format!("Nu har jag gjort diverse uppslag och scrape:at nätet och det är inte förrän *{}* som du behöver tidrapportera!", last_workday)); - - sleep_and_send_time_report_response(&http_client, &response_url, &map) - } - } - Err(error) => { - println!("failed to get last work day: {}", error); - - map.insert("text", String::from("Misslyckades stenhårt...")); - sleep_and_send_time_report_response(&http_client, &response_url, &map) - } - }; - }); - - format!("Ska ta en titt i kalendern...") -} - -fn sleep_and_send_time_report_response(http_client: &Client, url: &String, map: &HashMap<&str, String>) { - // To "fool" the user that we are actually calculating something - thread::sleep(Duration::from_secs(2)); - - let resp = http_client.post(url.as_str()) - .json(map) - .send(); - - match resp { - Ok(r) => { - if !r.status().is_success() { - println!("failed to send message, {}", r.status().as_str()); - let result = r.text(); - if result.is_ok() { - println!("{}", result.unwrap()); - } - } - } - Err(err) => { - println!("got exception while sending message: {}", err) - } - } -} - fn handle_mention_event(client: &impl SlackClientTrait, event: events::AppMentionEvent) -> String { let config = Configuration::read() .expect("couldn't read configuration when mentioned");