From a6e7521427465f0fc14ba6ebf47393adaddf3b44 Mon Sep 17 00:00:00 2001 From: alemi Date: Wed, 5 Jun 2024 00:54:43 +0200 Subject: [PATCH] feat: proof of concept threw this together this afternoon its not done but id better commit something already --- src/app.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ src/chat.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 51 ++++++++++++++++++++++++++++++++++++ src/proto.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 src/app.rs create mode 100644 src/chat.rs create mode 100644 src/main.rs create mode 100644 src/proto.rs diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..a6fe118 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,70 @@ +use crossterm::event::{Event, KeyCode, KeyModifiers}; +use ratatui::{prelude::*, widgets::{Block, Borders, Paragraph}}; +use futures::StreamExt; +use crate::chat::Timeline; + + +pub async fn run(term: &mut Terminal, timeline: Timeline) -> std::io::Result<()> { + let mut stream = crossterm::event::EventStream::new(); + let mut last_draw = tokio::time::Instant::now() - std::time::Duration::from_secs(1); + let mut input = String::new(); + + loop { + tokio::select! { + biased; + + res = stream.next() => match res { + None => break Ok(()), // EOF + Some(Err(e)) => break Err(e), + Some(Ok(ev)) => match ev { + Event::FocusGained | Event::FocusLost | Event::Resize(_, _) => {}, + Event::Mouse(_) => {}, // TODO handle mouse? eventually :tm: + Event::Paste(p) => input.push_str(&p), // TODO paste at cursor + Event::Key(k) => { + if k.modifiers == KeyModifiers::CONTROL && matches!(k.code, KeyCode::Char('q' | 'c')) { + break Ok(()); + } + + match k.code { + KeyCode::Enter => {}, + KeyCode::Backspace => {}, + KeyCode::Char(c) => input.push(c), + _ => {}, + } + }, + }, + }, + + // redraw anyway every second + _ = tokio::time::sleep_until(last_draw + std::time::Duration::from_secs(1)) => {}, + }; + + let tl_input = input.clone(); + let mut tl_text = timeline.read().await.join("\n"); + term.draw(|frame| { + + tl_text.push_str(&format!("\n{}", chrono::Utc::now())); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![ + Constraint::Percentage(100), + Constraint::Min(3), + ]) + .split(frame.size()); + + frame.render_widget( + Paragraph::new(tl_text) + .block(Block::new().borders(Borders::ALL)), + layout[0] + ); + frame.render_widget( + Paragraph::new(tl_input) + .block(Block::new().borders(Borders::ALL)), + layout[1] + ); + })?; + + last_draw = tokio::time::Instant::now(); + } +} diff --git a/src/chat.rs b/src/chat.rs new file mode 100644 index 0000000..69d0e5a --- /dev/null +++ b/src/chat.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +// TODO im so angry tokio-tungstenite makes me import this! +use futures_util::StreamExt; +use tokio::sync::RwLock; +use tokio_tungstenite::tungstenite::Message; + +use crate::proto::{http::RegisterResponse, ws::{ChatEvent, ChatEventInner}}; + +pub type Timeline = Arc>>; + +#[async_trait::async_trait] +pub trait PushableTimeline { + async fn push(&self, msg: String); +} + +#[async_trait::async_trait] +impl PushableTimeline for Timeline { + async fn push(&self, msg: String) { + self.write().await.push(msg) + } +} + +pub async fn worker(server: String, timeline: Timeline) { + timeline.push("[ ] connecting...".to_string()).await; + let registration : RegisterResponse = reqwest::Client::new() + .post(format!("https://{server}/api/chat/register")) + .send() + .await + .expect("failed sending registration request") + .json() + .await + .expect("failed parsing registration response"); + + timeline.push(format!("registered to chat: {registration:?}")).await; + + // TODO use url crate? + let ws_url = format!("wss://{server}/ws?accessToken={}", registration.access_token); + + timeline.push(format!("connecting to {ws_url}")).await; + + match tokio_tungstenite::connect_async(ws_url).await { + Err(e) => timeline.push(format!("[!] failed connecting: {e}")).await, + Ok((mut stream, _)) => while let Some(next) = stream.next().await { + match next { + Err(e) => timeline.push(format!("[!] error receiving chat message: {e}")).await, + Ok(msg) => match msg { + Message::Binary(_) => timeline.push("[!] received unexpected binary payload".to_string()).await, + Message::Frame(_) => timeline.push("[!] received unexpected raw frame".to_string()).await, + Message::Ping(_) | tokio_tungstenite::tungstenite::Message::Pong(_) => {}, // ignore + Message::Close(_) => timeline.push("[x] stream is closing".to_string()).await, + Message::Text(payload) => for line in payload.lines() { + match serde_json::from_str::(line) { + Err(e) => timeline.push(format!("[!] failed deserializing chat message: {e} -- {payload}")).await, + Ok(event) => match event.inner { + ChatEventInner::Chat { body, visible: _ } => + timeline.push(format!("{} | [{}]> {}", event.timestamp, event.user.display_name, dissolve::strip_html_tags(&body).join(" "))).await, + ChatEventInner::ConnectedUserInfo => + timeline.push(format!("{} | {} connected", event.timestamp, event.user.display_name)).await, + ChatEventInner::UserJoined => + timeline.push(format!("{} | {} joined", event.timestamp, event.user.display_name)).await, + ChatEventInner::UserParted => + timeline.push(format!("{} | {} left", event.timestamp, event.user.display_name)).await, + } + } + }, + } + } + }, + } + + timeline.write().await.push("[x] stream closed".to_string()); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0de7584 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,51 @@ +use clap::Parser; + +mod app; +mod chat; +mod proto; + +#[derive(Parser)] +/// simple owncast chat client to never leave your terminal +struct Args { + /// owncast server to connect to + server: String, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + + let timeline = chat::Timeline::default(); + let _tl = timeline.clone(); + let _server = args.server.clone(); + tokio::spawn(async move { + crate::chat::worker(_server, _tl).await + }); + + // setup terminal + crossterm::terminal::enable_raw_mode()?; + let mut stdout = std::io::stdout(); + crossterm::execute!( + stdout, + crossterm::terminal::EnterAlternateScreen + )?; + let backend = ratatui::backend::CrosstermBackend::new(stdout); + let mut terminal = ratatui::Terminal::new(backend)?; + terminal.hide_cursor()?; + + let res = app::run(&mut terminal, timeline).await; + + // restore terminal + crossterm::terminal::disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen, + )?; + terminal.show_cursor()?; + + if let Err(e) = res { + eprintln!("[!] Error executing app: {:?}", e); + } + + Ok(()) +} diff --git a/src/proto.rs b/src/proto.rs new file mode 100644 index 0000000..0915f63 --- /dev/null +++ b/src/proto.rs @@ -0,0 +1,63 @@ +pub mod http { + #[derive(Debug, serde::Serialize)] + #[allow(unused)] + pub struct RegisterRequest {} + + #[derive(Debug, serde::Deserialize)] + #[serde(rename_all = "camelCase")] + #[allow(unused)] + pub struct RegisterResponse { + pub id: String, + pub access_token: String, + pub display_name: String, + } +} + +pub mod ws { + #[derive(Debug, serde::Serialize)] + #[allow(unused)] + pub struct ChatAction { + pub r#type: String, // CHAT, ??? + pub body: String, + } + + #[derive(Debug, serde::Deserialize)] + pub struct ChatEvent { + pub id: String, + pub timestamp: chrono::DateTime, + pub user: ChatUser, + #[serde(flatten)] + pub inner: ChatEventInner, + } + + // TODO can i avoid repeating id,timestamp,user in each msg type?? + #[derive(Debug, serde::Deserialize)] + #[serde(tag = "type")] + pub enum ChatEventInner { + #[serde(rename = "CHAT")] + Chat { + body: String, + visible: bool, + }, + #[serde(rename = "CONNECTED_USER_INFO")] + ConnectedUserInfo, + #[serde(rename = "USER_JOINED")] + UserJoined, + #[serde(rename = "USER_PARTED")] + UserParted, + } + + #[derive(Debug, serde::Deserialize)] + #[serde(rename_all = "camelCase")] + #[allow(unused)] + pub struct ChatUser { + pub created_at: chrono::DateTime, + pub name_changed_at: chrono::DateTime, + pub id: String, + pub display_name: String, + pub previous_names: Vec, + pub display_color: i32, + pub is_bot: bool, + pub authenticated: bool, + } +}