use chrono::{Utc, DateTime, NaiveTime}; use config::{Config, PrevPost, Secrets, CommunitiesVector}; use lemmy_api_common::{ person::{Login, LoginResponse}, post::{CreatePost, GetPosts, GetPostsResponse}, sensitive::Sensitive, }; use lemmy_db_schema::{ ListingType, SortType, }; use once_cell::sync::Lazy; use reqwest::{blocking::Client, StatusCode}; use tui::{backend::{CrosstermBackend, Backend}, Terminal, widgets::{Block, Borders, Cell, Row, Table, Paragraph, Wrap}, layout::{Layout, Constraint, Direction, Alignment}, Frame, style::{Style, Modifier, Color}, text::{Span, Spans}}; use crossterm::{event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},}; use std::{thread::{sleep, self}, time::{self, Duration}, io, vec}; mod config; pub static CLIENT: Lazy = Lazy::new(|| { let client = Client::builder() .timeout(time::Duration::from_secs(30)) .connect_timeout(time::Duration::from_secs(30)) .build() .expect("build client"); client }); struct Bot { secrets: Secrets, config: Config, post_history: Vec, community_ids: CommunitiesVector, auth: Sensitive, start_time: DateTime, } impl Bot { pub(crate) fn new() -> Bot { Bot { secrets: Secrets::init(), config: Config::init(), post_history: PrevPost::load(), community_ids: CommunitiesVector::new(), auth: Sensitive::new("".to_string()), start_time: Utc::now(), } } pub(crate) fn login(&mut self) { let login_params = Login { username_or_email: self.secrets.lemmy.get_username(), password: self.secrets.lemmy.get_password(), totp_2fa_token: None, }; let res = CLIENT .post(self.config.instance.clone() + "/api/v3/user/login") .json(&login_params) .send() .unwrap(); if res.status() == StatusCode::OK { let data: &LoginResponse = &res.json().unwrap(); let jwt = data.jwt.clone().expect("JWT Token could not be acquired"); self.auth = jwt; } else { println!("Error Code: {:?}", res.status()); panic!("JWT Token could not be acquired"); } } pub(crate) fn post(&mut self, post_data: CreatePost) { let res = CLIENT .post(self.config.instance.clone() + "/api/v3/post") .json(&post_data) .send() .unwrap(); } pub(crate) fn run_once(&mut self, mut prev_time: NaiveTime) { self.start_time = Utc::now(); if self.start_time.time() - prev_time > chrono::Duration::seconds(6) { // Prod should use hours, add command line switch later and read duration from config prev_time = self.start_time.time(); self.config.load(); self.community_ids.load(&self.auth, &self.config.instance); } // Start the polling process // Get all feed URLs (use cache) let post_queue: Vec = self.config.check_feeds(&mut self.post_history, &self.community_ids, &self.auth); post_queue.iter().for_each(|post| { println!("Posting: {}", post.name); self.post(post.clone()); }); } pub(crate) fn idle(&self) { while Utc::now().time() - self.start_time.time() < chrono::Duration::seconds(60) { sleep(time::Duration::from_secs(10)); } } } fn list_posts(auth: &Sensitive, base: String) -> GetPostsResponse { let params = GetPosts { type_: Some(ListingType::Local), sort: Some(SortType::New), auth: Some(auth.clone()), ..Default::default() }; let res = CLIENT .get(base + "/api/v3/post/list") .query(¶ms) .send() .unwrap() .text() .unwrap(); return serde_json::from_str(&res).unwrap(); } fn run_bot(mut terminal: Terminal>) { // Get all needed auth tokens at the start let mut old = Utc::now().time(); let mut this = Bot::new(); this.login(); this.community_ids.load(&this.auth, &this.config.instance); // Enter a loop (not for debugging) loop { let _ = enable_raw_mode(); let _ = execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture); this.run_once(old); // Update UI terminal.draw(|f| { ui(f, &this); }).unwrap(); this.idle(); disable_raw_mode().unwrap(); execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture ).unwrap(); terminal.show_cursor().unwrap(); } } fn ui(f: &mut Frame, state: &Bot) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints( [ Constraint::Percentage(15), Constraint::Percentage(90), ].as_ref() ) .split(f.size()); // Account Infos let account_info = vec![ Row::new(vec![ Cell::from(format!("Lemmy Username: {} |", state.secrets.lemmy.username)), Cell::from(format!("Lemmy Instance: {} |", state.config.instance)) ]) ]; let block = Table::new(account_info) .block(Block::default().title("Account info").borders(Borders::ALL)) .widths(&[ Constraint::Percentage(15), Constraint::Percentage(20), Constraint::Percentage(20), Constraint::Percentage(20), Constraint::Percentage(20), ]); f.render_widget(block, chunks[0]); // Post Table let selected_style = Style::default().add_modifier(Modifier::REVERSED); let normal_style = Style::default().bg(Color::Blue); let header_cells = ["Series", "Community", "Reddit", "Discord", "Last Post"] .iter() .map(|h| Cell::from(*h).style(Style::default().fg(Color::Red))); let header = Row::new(header_cells) .style(normal_style) .height(1) .bottom_margin(1); let rows = state.post_history.iter().map(|post| { let config = &state.config.feeds[post.id]; Row::new([Cell::from(post.title.clone()), Cell::from(format!("{}", config.communities.chapter)), Cell::from(format!("{}", config.reddit.enabled as u8)), Cell::from("0"), Cell::from(post.last_post_url.clone())]).height(1 as u16).bottom_margin(1) }); let t = Table::new(rows) .header(header) .block(Block::default().borders(Borders::ALL).title("Table")) .highlight_style(selected_style) .highlight_symbol(">> ") .widths(&[ Constraint::Percentage(30), Constraint::Percentage(10), Constraint::Percentage(5), Constraint::Percentage(5), Constraint::Percentage(50), ]); f.render_widget(t, chunks[1]); } fn main() -> Result<(), io::Error> { let stdout = io::stdout(); let backend = CrosstermBackend::new(&stdout); let terminal = Terminal::new(backend)?; run_bot(terminal); Ok(()) }