use chrono::Utc; use config::{Config, PrevPost, Secrets, LemmyCommunities}; use lemmy_api_common::{ person::{Login, LoginResponse}, post::{CreatePost, GetPosts, GetPostsResponse}, sensitive::Sensitive, community::{ListCommunities, ListCommunitiesResponse}, }; use lemmy_db_schema::{ newtypes::{CommunityId, LanguageId}, ListingType, SortType, }; use once_cell::sync::Lazy; use reqwest::{blocking::Client, StatusCode}; use tui::{backend::{CrosstermBackend, Backend}, Terminal, widgets::{Widget, Block, Borders, Cell, Row, Table}, layout::{Layout, Constraint, Direction}, Frame, style::{Style, Modifier, Color}}; 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}; use url::Url; use crate::config::FeedData; 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, } impl Bot { pub(crate) fn new() -> Bot { Bot { secrets: Secrets::load(), config: Config::load(), post_history: PrevPost::load(), community_ids: CommunitiesVector::new(), auth: Sensitive::new("".to_string()), } } pub(crate) fn login(&mut self) { let login_params = Login { username_or_email: self.secrets.lemmy.get_username(), password: self.secrets.lemmy.get_password(), }; 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(); } } 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(); } struct CommunitiesVector { ids: Vec<(CommunityId, String)>, } impl CommunitiesVector { fn new() -> CommunitiesVector { CommunitiesVector{ids: vec![]} } fn load(&mut self, auth: &Sensitive, base: &String) { let params = ListCommunities { auth: Some(auth.clone()), ..Default::default() }; let res = CLIENT .get(base.clone() + "/api/v3/community/list") .query(¶ms) .send() .unwrap() .text() .unwrap(); let site_data: ListCommunitiesResponse = serde_json::from_str(&res).unwrap(); let mut ids = [].to_vec(); site_data.communities.iter().for_each(|entry| { let new_id = (entry.community.id, entry.community.name.clone()); ids.push(new_id); }); self.ids = ids; } fn find(&self, name: &LemmyCommunities) -> CommunityId { let mut ret_id = CommunityId(0); self.ids.iter().for_each(|id| { let id_name = &id.1; if &name.to_string() == id_name { ret_id = id.0; } }); return ret_id; } } fn run_bot() { // Get all needed auth tokens at the start let mut old = Utc::now().time(); let mut this = Bot::new(); println!("{}", this.secrets.lemmy.username); this.login(); this.community_ids.load(&this.auth, &this.config.instance); // Create empty eTag list println!("TODO: Etag list"); // Enter a loop (not for debugging) loop { let start = Utc::now(); print!("\x1B[2J\x1B[1;1H"); println!( "Started loop at {} {}", start.format("%H:%M:%S"), start.timezone() ); if start.time() - old > chrono::Duration::seconds(6) { old = start.time(); this.config = Config::load(); this.community_ids.load(&this.auth, &this.config.instance); } // Start the polling process // Get all feed URLs (use cache) let mut post_queue: Vec = vec![]; this.config.feeds.iter().for_each(|feed| { let res = CLIENT .get(feed.feed_url.clone()) .send() .unwrap() .text() .unwrap(); let data: FeedData = serde_json::from_str(&res).unwrap(); let mut prev_post_idx: Option = None; let mut do_post = true; this.post_history .iter() .enumerate() .for_each(|(idx, post)| { if &post.last_post_url == &data.items[0].url { do_post = false; } else if &post.title == &data.title { prev_post_idx = Some(idx); } }); if do_post { let item = &data.items[0]; let new_post = CreatePost { name: item.title.clone(), community_id: this.community_ids.find(&feed.communities.chapter), url: Some(Url::parse(&item.url).unwrap()), body: Some( "[Reddit](https://reddit.com)\n\n[Discord](https://discord.com)".into(), ), honeypot: None, nsfw: Some(false), language_id: Some(LanguageId(37)), // TODO get this id once every few hours per API request, the ordering of IDs suggests that the EN Id might change in the future auth: this.auth.clone(), }; post_queue.push(new_post); match prev_post_idx { Some(idx) => { this.post_history[idx].title = data.title; this.post_history[idx].last_post_url = item.url.clone(); } None => this.post_history.push(PrevPost { title: data.title, last_post_url: item.url.clone(), }), } } sleep(time::Duration::from_millis(100)); // Should prevent dos-ing J-Novel servers }); PrevPost::save(&this.post_history); post_queue.iter().for_each(|post| { println!("Posting: {}", post.name); this.post(post.clone()); }); while Utc::now().time() - start.time() < chrono::Duration::seconds(60) { sleep(time::Duration::from_secs(10)); } } } fn ui(f: &mut Frame) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints( [ Constraint::Percentage(10), Constraint::Percentage(90), ].as_ref() ) .split(f.size()); let block = Block::default() .title("Block") .borders(Borders::ALL); f.render_widget(block, chunks[0]); let selected_style = Style::default().add_modifier(Modifier::REVERSED); let normal_style = Style::default().bg(Color::Blue); let header_cells = ["Series", "Community", "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 = [ Row::new([Cell::from("a1"), Cell::from("a2"), Cell::from("a3")]).height(1 as u16).bottom_margin(1), Row::new([Cell::from("b1"), Cell::from("b2"), Cell::from("b3")]).height(1 as u16).bottom_margin(1), Row::new([Cell::from("unicorn"), Cell::from("c2"), Cell::from("c3")]).height(2 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(50), Constraint::Length(30), Constraint::Min(10), ]); f.render_widget(t, chunks[1]); } fn main() -> Result<(), io::Error> { let stdout = io::stdout(); let backend = CrosstermBackend::new(&stdout); let mut terminal = Terminal::new(backend)?; terminal.draw(|f| { ui(f); })?; thread::sleep(Duration::from_secs(5)); disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; Ok(()) //run_bot(); }