lemmy_scheduled_post_bot/
lib.rs1mod config;
8mod error;
9mod post;
10mod resolve_default;
11mod settings;
12mod template;
13mod util;
14
15use std::fs;
16use chrono::Utc;
17use lemmy_client::{
18 lemmy_api_common::{
19 community::GetCommunity, lemmy_db_schema::{newtypes::CommunityId, SortType}, lemmy_db_views::structs::PostView, person::{Login, LoginResponse}, post::{CreatePost, GetPosts, GetPostsResponse}, LemmyErrorType
20 }, ClientOptions, LemmyClient, LemmyRequest
21};
22pub use config::Config;
23pub use post::{Post, PostOptions};
24pub use settings::Settings;
25
26pub async fn process_posts_from_file(config_file: &str) -> Result<(), Box<dyn std::error::Error>> {
30 let config_str = fs::read_to_string(config_file)?;
31 let config: Config = toml::from_str(&config_str)?;
32
33 match process_posts(config).await {
34 Err(e) => Err(e),
35 Ok(_) => Ok(()),
36 }
37}
38
39pub async fn process_posts(Config { settings, defaults, post }: Config) -> Result<(), Box<error::Error>> {
43 let now = Utc::now();
45 let post = post.into_iter()
46 .filter(|p| p.post_at.cmp(&now).is_le())
47 .min_by_key(|p| now - p.post_at);
48
49 let post = match post {
50 None => {
52 println!("Nothing to post");
53 return Ok(());
54 }
55 Some(p) => p,
56 };
57
58 let post = post.evaluate(defaults.as_ref());
59
60 if !settings.enabled.unwrap_or(false) {
61 println!("Next post is {}", util::fos(&post.options.title));
62 return Ok(());
63 }
64
65 let mut client = LemmyClient::new(ClientOptions {
66 domain: settings.instance.clone(),
67 secure: true,
68 });
69
70 login(&settings, &mut client).await?;
71
72 let community_id = match get_community_id(&settings.community, &client).await {
74 Err(err) => return error::from_lemmy_error(err),
75 Ok(c) => c,
76 };
77
78 let check_res = is_already_posted(&post, &client, &settings.community).await;
80 if let Ok(true) = check_res {
81 println!("Post with title {} has already been posted", util::fos(&post.options.title));
82 return Ok(());
83 }
84 else if let Err(err) = check_res {
85 return error::from_lemmy_error(err);
86 }
87
88 println!("Making post with title {} to {}/c/{}",
89 util::fos(&post.options.title),
90 settings.instance,
91 settings.community);
92
93 let res = client.create_post(LemmyRequest {
94 body: CreatePost {
95 community_id,
96 name: post.options.title.expect("Posts must have a title!"),
97 url: post.options.link,
98 body: post.options.body,
99 custom_thumbnail: post.options.thumbnail,
100 alt_text: post.options.alt_text,
101 ..Default::default()
102 },
103 jwt: None,
104 });
105 match res.await {
106 Err(err) => error::from_lemmy_error(err),
107 Ok(_) => Ok(()),
108 }
109}
110
111async fn login(settings: &Settings, client: &mut LemmyClient) -> Result<(), Box<error::Error>> {
112
113 let res = client.login(LemmyRequest {
115 body: Login {
116 username_or_email: settings.username_or_email.clone().into(),
117 password: settings.password.clone().into(),
118 totp_2fa_token: None,
119 },
120 jwt: None,
121 }).await;
122
123 match res {
125 Ok(LoginResponse { jwt: Some(jwt), .. }) => {
126 LemmyClient::headers_mut(client).insert(
127 String::from("Authorization"),
128 format!("Bearer {}", jwt.to_string()),
129 );
130 },
131 Ok(res) => {
132 return error::from_str(&format!(
133 "Login pending, reg = {}, email = {}",
134 res.registration_created,
135 res.verify_email_sent,
136 ));
137 },
138 Err(err) => {
139 return error::from_lemmy_error(err);
140 },
141 };
142
143 Ok(())
144}
145
146async fn get_community_id(name: &str, client: &LemmyClient) -> Result<CommunityId, LemmyErrorType> {
147 let community = client.get_community(LemmyRequest {
148 body: GetCommunity {
149 name: Some(String::from(name)),
150 id: None,
151 },
152 jwt: None,
153 })
154 .await?;
155
156 Ok(community.community_view.community.id)
157}
158
159async fn is_already_posted(
160 post: &Post,
161 client: &LemmyClient,
162 community: &str,
163) -> Result<bool, LemmyErrorType> {
164 let my_id = client.get_site(LemmyRequest::empty()).await?
166 .my_user.expect("Logged in but no user?").local_user_view.person.id;
167
168 let mut req = LemmyRequest {
169 body: GetPosts {
170 community_name: Some(String::from(community)),
171 sort: Some(SortType::New),
172 page_cursor: None,
173 ..Default::default()
174 },
175 jwt: None,
176 };
177
178 let mut posts: Vec<PostView> = vec![];
180 loop {
181 let recent_posts = match client.list_posts(req.clone()).await {
183 Ok(GetPostsResponse { posts, next_page }) => {
184 req.body.page_cursor = next_page;
185 posts
186 },
187 Err(err) => return Err(err),
188 };
189
190 if recent_posts.first().is_none() {
192 break;
193 }
194 else if let Some(first) = recent_posts.first() {
195 if first.post.published < post.post_at {
196 break;
197 }
198 }
199
200 let mut is_last_page = false;
202 if let Some(last) = recent_posts.last() {
203 is_last_page = last.post.published < post.post_at;
204 }
205
206 for possible in recent_posts.into_iter().filter(
208 |p| p.creator.id == my_id && p.post.published >= post.post_at,
209 ) {
210 posts.push(possible);
211 }
212
213 if is_last_page {
215 break;
216 }
217 }
218
219 Ok(posts.iter()
220 .find(|p| {
221 Some(&p.post.name) == post.options.title.as_ref()
222 })
223 .is_some(),
224 )
225}