Noreposts Feed

Add publish command to register feed with Bluesky

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