aboutsummaryrefslogtreecommitdiff
path: root/src/bin/delete-recent.rs
blob: 54865551a300d8d9ec16e1ddbd7bd3aa610e1290 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
use std::{env, fs};

use atrium_api::{
    app::bsky::feed::post::RecordData,
    com::atproto::repo::list_records::{OutputData, ParametersData},
    types::{
        TryFromUnknown,
        string::{AtIdentifier, Datetime},
    },
};
use bsky_sdk::BskyAgent;
use chrono::{Duration, Utc};
use clap::Parser;
use serde::Deserialize;

#[derive(Debug, Parser)]
#[command(about = "Delete posts created by the account during the last hour")]
struct Args {
    /// Print matching posts without deleting them.
    #[arg(long)]
    dry: bool,
}

#[derive(Deserialize)]
struct Credentials {
    identifier: String,
    password: String,
}

#[tokio::main]
async fn main() -> Result<(), String> {
    tracing_subscriber::fmt::init();

    let args = Args::parse();
    let credentials = load_credentials()?;
    let repo: AtIdentifier = credentials
        .identifier
        .parse()
        .map_err(|error| format!("invalid Bluesky identifier: {error}"))?;
    let cutoff = Datetime::new((Utc::now() - Duration::hours(1)).fixed_offset());

    let agent = BskyAgent::builder()
        .build()
        .await
        .map_err(|error| format!("failed to create Bluesky client: {error}"))?;
    agent
        .login(credentials.identifier, credentials.password)
        .await
        .map_err(|error| format!("failed to log in to Bluesky: {error}"))?;

    let mut cursor = None;
    let mut posts = Vec::new();
    let mut reached_cutoff = false;

    while !reached_cutoff {
        let output = agent
            .api
            .com
            .atproto
            .repo
            .list_records(
                ParametersData {
                    collection: "app.bsky.feed.post".parse().unwrap(),
                    cursor,
                    limit: Some(100.try_into().unwrap()),
                    repo: repo.clone(),
                    // listRecords is newest-first by default.
                    reverse: None,
                }
                .into(),
            )
            .await
            .map_err(|error| format!("failed to list posts: {error}"))?;

        let OutputData {
            records,
            cursor: next_cursor,
        } = output.data;
        for record in records {
            let record = record.data;
            let post = RecordData::try_from_unknown(record.value)
                .map_err(|error| format!("failed to parse post {}: {error}", record.uri))?;
            if post.created_at < cutoff {
                reached_cutoff = true;
                break;
            }
            posts.push((record.uri, post.text));
        }

        cursor = next_cursor;
        if cursor.is_none() {
            break;
        }
    }

    if posts.is_empty() {
        println!("No posts from the last hour.");
        return Ok(());
    }

    for (uri, text) in &posts {
        if args.dry {
            println!("Would delete {uri}: {text}");
        } else {
            tracing::info!(%uri, "deleting post");
            agent
                .delete_record(uri)
                .await
                .map_err(|error| format!("failed to delete {uri}: {error}"))?;
            println!("Deleted {uri}: {text}");
        }
    }

    if args.dry {
        println!("{} post(s) would be deleted.", posts.len());
    } else {
        println!("Deleted {} post(s).", posts.len());
    }

    Ok(())
}

fn load_credentials() -> Result<Credentials, String> {
    let path = env::var("BSKY_CREDENTIALS_FILE")
        .map_err(|_| "BSKY_CREDENTIALS_FILE is not set".to_owned())?;
    let data = fs::read(&path).map_err(|error| format!("failed to read {path}: {error}"))?;
    serde_json::from_slice(&data).map_err(|error| format!("failed to parse {path}: {error}"))
}