/*
* This file is part of arsnova-client
*
* Copyright (C) 2023 Paul-Christian Volkmer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see .
*/
use std::error;
use std::fmt::{Display, Formatter};
use std::marker::PhantomData;
use std::time::Duration;
use base64::engine::general_purpose::STANDARD_NO_PAD;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use reqwest::{IntoUrl, StatusCode};
use serde::Deserialize;
use serde_json::json;
use tokio::join;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use url::Url;
use crate::client::ClientError::{
ConnectionError, LoginError, ParserError, RoomNotFoundError, UrlError,
};
#[derive(Deserialize, Debug)]
struct LoginResponse {
#[serde(rename = "token")]
token: String,
}
#[derive(Deserialize, Debug)]
struct TokenClaim {
sub: String,
}
#[derive(Deserialize, Debug)]
struct MembershipResponse {
#[serde(rename = "id")]
id: String,
#[serde(rename = "shortId")]
short_id: String,
#[serde(rename = "name")]
name: String,
}
struct WsConnectMessage {
token: String,
}
impl WsConnectMessage {
fn new(token: &str) -> WsConnectMessage {
WsConnectMessage {
token: token.to_string(),
}
}
}
impl Display for WsConnectMessage {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let str = format!(
"CONNECT\ntoken:{}\naccept-version:1.2,1.1,1.0\nheart-beat:20000,0\n\n\0",
self.token
);
write!(f, "{}", str)
}
}
struct WsSubscribeMessage {
room_id: String,
}
impl WsSubscribeMessage {
fn new(room_id: &str) -> WsSubscribeMessage {
WsSubscribeMessage {
room_id: room_id.to_string(),
}
}
}
#[derive(Debug)]
struct WsFeedbackMessage {
body: WsFeedbackBody,
}
impl WsFeedbackMessage {
fn parse(raw: &str) -> Result {
let parts = raw.split("\n\n");
match serde_json::from_str::(parts.last().unwrap().replace('\0', "").trim())
{
Ok(body) => Ok(WsFeedbackMessage { body }),
Err(_) => Err(()),
}
}
}
#[derive(Deserialize, Debug)]
struct WsFeedbackBody {
#[serde(rename = "type")]
body_type: String,
payload: WsFeedbackPayload,
}
#[derive(Deserialize, Debug)]
struct WsFeedbackPayload {
values: [u16; 4],
}
impl WsFeedbackPayload {
fn get_feedback(self) -> Feedback {
Feedback::from_values(self.values)
}
}
impl Display for WsSubscribeMessage {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let str = format!(
"SUBSCRIBE\nid:sub-6\ndestination:/topic/{}.feedback.stream\n\n\0",
self.room_id
);
write!(f, "{}", str)
}
}
#[derive(Debug)]
pub struct RoomInfo {
pub id: String,
pub short_id: String,
pub name: String,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct Feedback {
pub very_good: u16,
pub good: u16,
pub bad: u16,
pub very_bad: u16,
}
impl Feedback {
pub fn from_values(values: [u16; 4]) -> Feedback {
Feedback {
very_good: values[0],
good: values[1],
bad: values[2],
very_bad: values[3],
}
}
pub fn count_votes(&self) -> u16 {
self.very_good + self.good + self.bad + self.very_bad
}
}
#[allow(dead_code)]
pub enum FeedbackHandler {
Fn(fn(&Feedback)),
Sender(Sender),
}
/// A possible feedback value
pub enum FeedbackValue {
VeryGood,
A,
Good,
B,
Bad,
C,
VeryBad,
D,
}
impl FeedbackValue {
/// Returns internal u8 representation
fn into_u8(self) -> u8 {
match self {
FeedbackValue::VeryGood | FeedbackValue::A => 0,
FeedbackValue::Good | FeedbackValue::B => 1,
FeedbackValue::Bad | FeedbackValue::C => 2,
FeedbackValue::VeryBad | FeedbackValue::D => 3,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClientError {
ConnectionError,
LoginError,
RoomNotFoundError(String),
ParserError(String),
UrlError,
}
impl Display for ClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ConnectionError => write!(f, "Cannot connect"),
LoginError => write!(f, "Cannot login"),
RoomNotFoundError(short_id) => write!(f, "Requested room '{}' not found", short_id),
ParserError(msg) => write!(f, "Cannot parse response: {}", msg),
UrlError => write!(f, "Cannot parse given URL"),
}
}
}
impl error::Error for ClientError {}
pub struct LoggedIn;
pub struct LoggedOut;
/// An asynchronous `Client` to make Requests with.
///
/// The client can be created with an URL to an ARSnova API endpoint.
pub struct Client {
api_url: String,
http_client: reqwest::Client,
token: Option,
state: PhantomData,
}
impl Client {
/// Constructs a new ARSnova client
///
/// This method fails whenever the supplied Url cannot be parsed.
///
/// If successful the result will be of type `Client`
pub fn new(api_url: U) -> Result {
let client = reqwest::Client::builder()
.user_agent(format!("arsnova-cli-client/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|_| ConnectionError)?;
Ok(Client {
api_url: api_url.into_url().map_err(|_| UrlError)?.to_string(),
http_client: client,
token: None,
state: PhantomData::,
})
}
}
impl Client {
/// Tries to login and request a new token if client is not logged in yet
///
/// This method fails if a connection error occurs or the response cannot
/// be handled.
///
/// If successful the result will be of type `Client`
pub async fn guest_login(self) -> Result, ClientError> {
match self
.http_client
.post(format!("{}/auth/login/guest", self.api_url))
.send()
.await
{
Ok(res) => match res.json::().await {
Ok(res) => Ok(Client {
api_url: self.api_url,
http_client: self.http_client,
token: Some(res.token),
state: PhantomData::,
}),
Err(_) => Err(LoginError),
},
Err(_) => Err(ConnectionError),
}
}
}
impl Client {
/// Get user ID extracted from client token
///
/// This method fails if the token cannot be parsed
fn get_user_id(&self) -> Result {
let token = self.token.clone().unwrap_or_default();
let mut token_parts = token.split('.');
match token_parts.nth(1) {
None => Err(ParserError("Unparsable token".into())),
Some(part) => match STANDARD_NO_PAD.decode(part) {
Ok(d) => match serde_json::from_str::(
&String::from_utf8(d).unwrap_or_default(),
) {
Ok(claim) => Ok(claim.sub),
Err(err) => Err(ParserError(format!("Unparsable token claim: {}", err))),
},
Err(err) => Err(ParserError(format!("Unparsable token: {}", err))),
},
}
}
/// Logout the client and discard existing token if not logged in
///
/// If successful the result will be of type `Client`
pub fn logout(self) -> Client {
Client {
api_url: self.api_url,
http_client: self.http_client,
token: None,
state: PhantomData::,
}
}
/// Requests `RoomInfo` for given 8-digit room ID
///
/// This method fails on connection or response errors and if
/// no room is available with given room ID.
pub async fn get_room_info(&self, short_id: &str) -> Result {
let token = self.token.as_ref().unwrap();
let membership_response = match self
.http_client
.post(format!(
"{}/room/~{}/request-membership",
self.api_url, short_id
))
.bearer_auth(token.to_string())
.header("ars-room-role", "PARTICIPANT")
.header("content-type", "application/json")
.body("{}")
.send()
.await
{
Ok(res) => match res.status() {
StatusCode::OK => res
.json::()
.await
.map_err(|err| ParserError(err.to_string()))?,
StatusCode::NOT_FOUND => return Err(RoomNotFoundError(short_id.into())),
_ => return Err(ConnectionError),
},
Err(_) => {
return Err(ConnectionError);
}
};
Ok(RoomInfo {
id: membership_response.id,
short_id: membership_response.short_id,
name: membership_response.name,
description: String::new(),
})
}
/// Requests `Feedback` for given 8-digit room ID
///
/// This method fails on connection or response errors and if
/// no room is available with given room ID.
pub async fn get_feedback(&self, short_id: &str) -> Result {
let room_info = self.get_room_info(short_id).await?;
match self
.http_client
.get(format!("{}/room/{}/survey", self.api_url, room_info.id))
.bearer_auth(self.token.as_ref().unwrap_or(&"".to_string()).to_string())
.send()
.await
{
Ok(res) => match res.status() {
StatusCode::OK => Ok(Feedback::from_values(
res.json::<[u16; 4]>()
.await
.map_err(|err| ParserError(err.to_string()))?,
)),
StatusCode::NOT_FOUND => Err(RoomNotFoundError(short_id.into())),
_ => Err(ConnectionError),
},
Err(_) => Err(ConnectionError),
}
}
/// Register feedback channel receiver and send incoming feedback to service
///
/// This method fails on connection or response errors and if
/// no room is available with given room ID.
pub async fn register_feedback_receiver(
&self,
short_id: &str,
mut receiver: Receiver,
) -> Result<(), ClientError> {
let room_info = self.get_room_info(short_id).await?;
let ws_url = self.api_url.replace("http", "ws");
let (socket, _) = connect_async(Url::parse(&format!("{}/ws/websocket", ws_url)).unwrap())
.await
.map_err(|_| ConnectionError)?;
let (mut write, _) = socket.split();
let user_id = self.get_user_id().unwrap_or_default();
if write
.send(Message::Text(
WsConnectMessage::new(self.token.as_ref().unwrap()).to_string(),
))
.await
.is_ok()
{
return match write
.send(Message::Text(
WsSubscribeMessage::new(&room_info.id).to_string(),
))
.await
{
Ok(_) => loop {
if let Some(value) = receiver.recv().await {
let payload = json!({
"type": "CreateFeedback",
"payload": {
"roomId": room_info.id,
"userId": user_id,
"value": value.into_u8()
}
})
.to_string();
let _ = write
.send(Message::Text(format!(
"SEND\ndestination:/queue/feedback.command\ncontent-type:application/json\ncontent-length:{}\n\n{}\0",
payload.chars().count(),
payload,
))).await;
};
},
Err(_) => Err(ConnectionError),
};
}
Err(ConnectionError)
}
/// Registers a handler to get notifications on feedback change.
///
/// This is done by using websocket connections to ARSnova.
///
/// This method fails on connection or response errors and if
/// no room is available with given room ID.
pub async fn on_feedback_changed(
&self,
short_id: &str,
handler: FeedbackHandler,
) -> Result<(), ClientError> {
let room_info = self.get_room_info(short_id).await?;
let ws_url = self.api_url.replace("http", "ws");
let (socket, _) = connect_async(Url::parse(&format!("{}/ws/websocket", ws_url)).unwrap())
.await
.map_err(|_| ConnectionError)?;
let (mut write, read) = socket.split();
if write
.send(Message::Text(
WsConnectMessage::new(self.token.as_ref().unwrap()).to_string(),
))
.await
.is_ok()
{
match write
.send(Message::Text(
WsSubscribeMessage::new(&room_info.id).to_string(),
))
.await
{
Ok(_) => {}
Err(_) => return Err(ConnectionError),
}
let jh1 = read.for_each(|msg| async {
if let Ok(msg) = msg {
if msg.is_text() && msg.clone().into_text().unwrap().starts_with("MESSAGE") {
if let Ok(msg) = WsFeedbackMessage::parse(msg.to_text().unwrap()) {
if msg.body.body_type == "FeedbackChanged" {
let feedback = msg.body.payload.get_feedback();
match &handler {
FeedbackHandler::Fn(f) => f(&feedback),
FeedbackHandler::Sender(tx) => {
let _ = tx.send(feedback).await;
}
};
}
}
}
}
});
let jh2 = tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(15)).await;
let _ = write.send(Message::Text("\n".to_string())).await;
}
});
let _ = join!(jh1, jh2);
return Ok(());
}
Err(ConnectionError)
}
}