tangled
alpha
login
or
join now
vitorpy.com
/
noreposts-atproto-feed
0
fork
atom
Noreposts Feed
0
fork
atom
overview
issues
pulls
pipelines
Add publish command to register feed with Bluesky
Vitor Py Braga
5 months ago
5f08f994
d655ecb7
+143
-50
2 changed files
expand all
collapse all
unified
split
src
main.rs
publish.rs
+25
-3
src/main.rs
···
16
16
mod database;
17
17
mod feed_algorithm;
18
18
mod jetstream_consumer;
19
19
+
mod publish;
19
20
mod types;
20
21
21
22
use crate::{
···
30
31
#[command(name = "following-no-reposts-feed")]
31
32
#[command(about = "A Bluesky feed generator for following without reposts")]
32
33
struct Args {
34
34
+
#[command(subcommand)]
35
35
+
command: Option<Command>,
36
36
+
33
37
#[arg(long, env = "DATABASE_URL", default_value = "sqlite:./feed.db")]
34
38
database_url: String,
35
39
···
37
41
port: u16,
38
42
39
43
#[arg(long, env = "FEEDGEN_HOSTNAME")]
40
40
-
hostname: String,
44
44
+
hostname: Option<String>,
41
45
42
46
#[arg(long, env = "FEEDGEN_SERVICE_DID")]
43
43
-
service_did: String,
47
47
+
service_did: Option<String>,
44
48
45
49
#[arg(long, env = "JETSTREAM_HOSTNAME", default_value = "jetstream1.us-east.bsky.network")]
46
50
jetstream_hostname: String,
47
51
}
48
52
53
53
+
#[derive(Parser)]
54
54
+
enum Command {
55
55
+
/// Publish the feed to Bluesky
56
56
+
Publish,
57
57
+
/// Run the feed generator server (default)
58
58
+
Serve,
59
59
+
}
60
60
+
49
61
#[derive(Clone)]
50
62
struct AppState {
51
63
db: Arc<Database>,
···
59
71
60
72
let args = Args::parse();
61
73
74
74
+
// Handle publish command
75
75
+
if matches!(args.command, Some(Command::Publish)) {
76
76
+
return publish::publish_feed().await;
77
77
+
}
78
78
+
79
79
+
// Default to serve mode
80
80
+
let service_did = args.service_did
81
81
+
.or_else(|| args.hostname.clone().map(|h| format!("did:web:{}", h)))
82
82
+
.expect("FEEDGEN_SERVICE_DID or FEEDGEN_HOSTNAME must be set");
83
83
+
62
84
// Initialize database
63
85
let db = Arc::new(Database::new(&args.database_url).await?);
64
86
db.migrate().await?;
65
87
66
88
let app_state = AppState {
67
89
db: Arc::clone(&db),
68
68
-
service_did: args.service_did.clone(),
90
90
+
service_did: service_did.clone(),
69
91
};
70
92
71
93
// Start Jetstream consumer
+118
-47
src/publish.rs
···
1
1
-
use anyhow::Result;
2
2
-
use atrium_api::{
3
3
-
agent::atp_agent::{store::MemorySessionStore, AtpAgent},
4
4
-
app::bsky::feed::generator::RecordData as FeedGeneratorRecord,
5
5
-
com::atproto::repo::create_record::InputData as CreateRecordInput,
6
6
-
};
7
7
-
use atrium_xrpc_client::reqwest::ReqwestClient;
8
8
-
use serde_json::json;
9
9
-
use std::env;
1
1
+
use anyhow::{anyhow, Result};
2
2
+
use reqwest::Client;
3
3
+
use serde::{Deserialize, Serialize};
4
4
+
use std::io::{self, Write};
5
5
+
6
6
+
#[derive(Debug, Serialize)]
7
7
+
struct LoginRequest {
8
8
+
identifier: String,
9
9
+
password: String,
10
10
+
}
11
11
+
12
12
+
#[derive(Debug, Deserialize)]
13
13
+
struct LoginResponse {
14
14
+
#[serde(rename = "accessJwt")]
15
15
+
access_jwt: String,
16
16
+
did: String,
17
17
+
handle: String,
18
18
+
}
19
19
+
20
20
+
#[derive(Debug, Serialize)]
21
21
+
struct PutRecordRequest {
22
22
+
repo: String,
23
23
+
collection: String,
24
24
+
rkey: String,
25
25
+
record: FeedGeneratorRecord,
26
26
+
}
27
27
+
28
28
+
#[derive(Debug, Serialize)]
29
29
+
struct FeedGeneratorRecord {
30
30
+
#[serde(rename = "$type")]
31
31
+
record_type: String,
32
32
+
did: String,
33
33
+
#[serde(rename = "displayName")]
34
34
+
display_name: String,
35
35
+
#[serde(skip_serializing_if = "Option::is_none")]
36
36
+
description: Option<String>,
37
37
+
#[serde(rename = "createdAt")]
38
38
+
created_at: String,
39
39
+
}
40
40
+
41
41
+
pub async fn publish_feed() -> Result<()> {
42
42
+
println!("=== Bluesky Feed Generator Publisher ===\n");
43
43
+
44
44
+
// Get user input
45
45
+
let handle = prompt("Enter your Bluesky handle: ")?;
46
46
+
let password = prompt_password("Enter your Bluesky password (App Password): ")?;
47
47
+
let record_name = prompt("Enter a short name for the record (shown in URL): ")?;
48
48
+
let display_name = prompt("Enter a display name for your feed: ")?;
49
49
+
let description = prompt_optional("Enter a brief description (optional): ")?;
10
50
11
11
-
#[tokio::main]
12
12
-
async fn main() -> Result<()> {
51
51
+
// Get feed generator DID from environment
13
52
dotenvy::dotenv().ok();
53
53
+
let feedgen_service_did = std::env::var("FEEDGEN_SERVICE_DID")
54
54
+
.or_else(|_| {
55
55
+
std::env::var("FEEDGEN_HOSTNAME")
56
56
+
.map(|hostname| format!("did:web:{}", hostname))
57
57
+
})
58
58
+
.map_err(|_| anyhow!("Please set FEEDGEN_SERVICE_DID or FEEDGEN_HOSTNAME in .env file"))?;
14
59
15
15
-
let identifier = env::var("BLUESKY_IDENTIFIER")?;
16
16
-
let password = env::var("BLUESKY_PASSWORD")?;
17
17
-
let hostname = env::var("FEEDGEN_HOSTNAME")?;
18
18
-
let service_did = env::var("FEEDGEN_SERVICE_DID")?;
60
60
+
println!("\nPublishing feed...");
19
61
20
20
-
let agent = AtpAgent::new(
21
21
-
ReqwestClient::new("https://bsky.social"),
22
22
-
MemorySessionStore::default(),
23
23
-
);
62
62
+
let client = Client::new();
63
63
+
let pds_url = "https://bsky.social";
64
64
+
65
65
+
// Login to get session
66
66
+
let login_response: LoginResponse = client
67
67
+
.post(format!("{}/xrpc/com.atproto.server.createSession", pds_url))
68
68
+
.json(&LoginRequest {
69
69
+
identifier: handle.clone(),
70
70
+
password,
71
71
+
})
72
72
+
.send()
73
73
+
.await?
74
74
+
.json()
75
75
+
.await?;
24
76
25
25
-
// Login to Bluesky
26
26
-
agent.login(&identifier, &password).await?;
77
77
+
println!("✓ Logged in as {}", login_response.did);
27
78
28
79
// Create feed generator record
29
80
let record = FeedGeneratorRecord {
30
30
-
avatar: None,
81
81
+
record_type: "app.bsky.feed.generator".to_string(),
82
82
+
did: feedgen_service_did,
83
83
+
display_name,
84
84
+
description: if description.is_empty() { None } else { Some(description) },
31
85
created_at: chrono::Utc::now().to_rfc3339(),
32
32
-
description: Some("A feed showing posts from people you follow, without any reposts. Clean, chronological timeline of original content only.".to_string()),
33
33
-
description_facets: None,
34
34
-
did: service_did.clone(),
35
35
-
display_name: "Following (No Reposts)".to_string(),
36
36
-
labels: None,
37
86
};
38
87
39
39
-
let input = CreateRecordInput {
40
40
-
collection: "app.bsky.feed.generator".parse()?,
41
41
-
record: record.into(),
42
42
-
repo: agent.session().await?.did.clone(),
43
43
-
rkey: Some("following-no-reposts".to_string()),
44
44
-
swap_commit: None,
45
45
-
validate: Some(true),
88
88
+
// Publish the record
89
89
+
let put_request = PutRecordRequest {
90
90
+
repo: login_response.did.clone(),
91
91
+
collection: "app.bsky.feed.generator".to_string(),
92
92
+
rkey: record_name.clone(),
93
93
+
record,
46
94
};
47
95
48
48
-
let response = agent
49
49
-
.service
50
50
-
.com
51
51
-
.atproto
52
52
-
.repo
53
53
-
.create_record(input.into())
54
54
-
.await?;
96
96
+
client
97
97
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", pds_url))
98
98
+
.header("Authorization", format!("Bearer {}", login_response.access_jwt))
99
99
+
.json(&put_request)
100
100
+
.send()
101
101
+
.await?
102
102
+
.error_for_status()?;
55
103
56
56
-
println!("Feed generator published successfully!");
57
57
-
println!("URI: {}", response.uri);
58
58
-
println!("CID: {}", response.cid);
59
59
-
println!();
60
60
-
println!("You can now access your feed at:");
61
61
-
println!("https://bsky.app/profile/{}/feed/following-no-reposts", agent.session().await?.handle);
104
104
+
println!("\n✅ Feed published successfully!");
105
105
+
println!("🔗 Feed AT-URI: at://{}/app.bsky.feed.generator/{}", login_response.did, record_name);
106
106
+
println!("\n🌐 You can view your feed at:");
107
107
+
println!(" https://bsky.app/profile/{}/feed/{}", login_response.handle, record_name);
108
108
+
println!("\nYou can now find and share your feed in the Bluesky app!");
62
109
63
110
Ok(())
64
111
}
112
112
+
113
113
+
fn prompt(message: &str) -> Result<String> {
114
114
+
print!("{}", message);
115
115
+
io::stdout().flush()?;
116
116
+
let mut input = String::new();
117
117
+
io::stdin().read_line(&mut input)?;
118
118
+
Ok(input.trim().to_string())
119
119
+
}
120
120
+
121
121
+
fn prompt_optional(message: &str) -> Result<String> {
122
122
+
print!("{}", message);
123
123
+
io::stdout().flush()?;
124
124
+
let mut input = String::new();
125
125
+
io::stdin().read_line(&mut input)?;
126
126
+
Ok(input.trim().to_string())
127
127
+
}
128
128
+
129
129
+
fn prompt_password(message: &str) -> Result<String> {
130
130
+
print!("{}", message);
131
131
+
io::stdout().flush()?;
132
132
+
let mut password = String::new();
133
133
+
io::stdin().read_line(&mut password)?;
134
134
+
Ok(password.trim().to_string())
135
135
+
}