lemmy_scheduled_post_bot/
lib.rs

1/*
2Lemmy Scheduled Post Bot - makes Lemmy posts on a schedule
3Copyright (C) 2024 Steven Vergenz
4Licensed under the terms of the General Public License 3.0 or later
5*/
6
7mod 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
26/// Loads the supplied config file and passes the result to [process_posts].
27/// # Arguments
28/// * `config_file` - A filesystem path to a config file
29pub 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
39/// Publish the next post from the supplied config
40/// # Arguments
41/// * `config` - A [Config] object
42pub async fn process_posts(Config { settings, defaults, post }: Config) -> Result<(), Box<error::Error>> {
43    // identify the post definition in the config whose scheduled post time most recently passed
44    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        // if nothing to post, just exit
51        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    // verify community exists
73    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    // verify it hasn't already been posted
79    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    // request log in
114    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    // handle login errors
124    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    // get the bot user info
165    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    // gather potential posts
179    let mut posts: Vec<PostView> = vec![];
180    loop {
181        // request a page of recent posts
182        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        // stop looking immediately if the newest post in the page is older than our expected post time
191        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        // stop looking after appending if last post is the same
201        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        // update the list of posts that might be it
207        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        // follow through on earlier check
214        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}