213 lines
7.1 KiB
Rust
213 lines
7.1 KiB
Rust
use anyhow::Result;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct NetworkConfig {
|
|
pub pds_api: String,
|
|
pub plc_api: String,
|
|
pub bsky_api: String,
|
|
pub web_url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Profile {
|
|
pub did: String,
|
|
pub handle: String,
|
|
pub display_name: Option<String>,
|
|
pub avatar: Option<String>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RepoDescription {
|
|
pub did: String,
|
|
pub handle: String,
|
|
#[serde(rename = "didDoc")]
|
|
pub did_doc: DidDoc,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DidDoc {
|
|
pub service: Vec<Service>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Service {
|
|
#[serde(rename = "serviceEndpoint")]
|
|
pub service_endpoint: String,
|
|
}
|
|
|
|
pub struct ProfileFetcher {
|
|
client: reqwest::Client,
|
|
}
|
|
|
|
impl ProfileFetcher {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
client: reqwest::Client::new(),
|
|
}
|
|
}
|
|
|
|
/// Get network configuration based on PDS
|
|
pub fn get_network_config(pds: &str) -> NetworkConfig {
|
|
match pds {
|
|
"bsky.social" | "bsky.app" => NetworkConfig {
|
|
pds_api: format!("https://{}", pds),
|
|
plc_api: "https://plc.directory".to_string(),
|
|
bsky_api: "https://public.api.bsky.app".to_string(),
|
|
web_url: "https://bsky.app".to_string(),
|
|
},
|
|
"syu.is" => NetworkConfig {
|
|
pds_api: "https://syu.is".to_string(),
|
|
plc_api: "https://plc.syu.is".to_string(),
|
|
bsky_api: "https://bsky.syu.is".to_string(),
|
|
web_url: "https://web.syu.is".to_string(),
|
|
},
|
|
_ => {
|
|
// Default to Bluesky network for unknown PDS
|
|
NetworkConfig {
|
|
pds_api: format!("https://{}", pds),
|
|
plc_api: "https://plc.directory".to_string(),
|
|
bsky_api: "https://public.api.bsky.app".to_string(),
|
|
web_url: "https://bsky.app".to_string(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetch DID and PDS from handle
|
|
pub async fn describe_repo(&self, handle: &str, pds: &str) -> Result<RepoDescription> {
|
|
let network_config = Self::get_network_config(pds);
|
|
let url = format!("{}/xrpc/com.atproto.repo.describeRepo", network_config.pds_api);
|
|
|
|
let response = self.client
|
|
.get(&url)
|
|
.query(&[("repo", handle)])
|
|
.send()
|
|
.await?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(anyhow::anyhow!("Failed to describe repo: {}", response.status()));
|
|
}
|
|
|
|
let repo_desc: RepoDescription = response.json().await?;
|
|
Ok(repo_desc)
|
|
}
|
|
|
|
/// Get user's PDS from their DID document
|
|
pub fn extract_pds_from_repo_desc(repo_desc: &RepoDescription) -> Option<String> {
|
|
repo_desc.did_doc.service.first().map(|service| {
|
|
// Extract hostname from service endpoint
|
|
let endpoint = &service.service_endpoint;
|
|
if let Some(url) = endpoint.strip_prefix("https://") {
|
|
if let Some(host) = url.split('/').next() {
|
|
return host.to_string();
|
|
}
|
|
}
|
|
endpoint.clone()
|
|
})
|
|
}
|
|
|
|
/// Fetch profile from bsky API
|
|
pub async fn get_profile(&self, did: &str, pds: &str) -> Result<Profile> {
|
|
let network_config = Self::get_network_config(pds);
|
|
let url = format!("{}/xrpc/app.bsky.actor.getProfile", network_config.bsky_api);
|
|
|
|
let response = self.client
|
|
.get(&url)
|
|
.query(&[("actor", did)])
|
|
.send()
|
|
.await?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(anyhow::anyhow!("Failed to get profile: {}", response.status()));
|
|
}
|
|
|
|
let profile_data: Value = response.json().await?;
|
|
|
|
let profile = Profile {
|
|
did: did.to_string(),
|
|
handle: profile_data["handle"].as_str().unwrap_or("").to_string(),
|
|
display_name: profile_data["displayName"].as_str().map(|s| s.to_string()),
|
|
avatar: profile_data["avatar"].as_str().map(|s| s.to_string()),
|
|
description: profile_data["description"].as_str().map(|s| s.to_string()),
|
|
};
|
|
|
|
Ok(profile)
|
|
}
|
|
|
|
/// Fetch complete profile information from handle and PDS
|
|
pub async fn fetch_profile_from_handle(&self, handle: &str, pds: &str) -> Result<Profile> {
|
|
println!("🔍 Fetching profile for handle: {} from PDS: {}", handle, pds);
|
|
|
|
// First, get DID from handle
|
|
let repo_desc = self.describe_repo(handle, pds).await?;
|
|
let did = repo_desc.did.clone();
|
|
|
|
// Determine the actual PDS from the DID document
|
|
let actual_pds = Self::extract_pds_from_repo_desc(&repo_desc)
|
|
.unwrap_or_else(|| pds.to_string());
|
|
|
|
println!("📍 Found DID: {} with PDS: {}", did, actual_pds);
|
|
|
|
// Get profile from the actual PDS
|
|
let profile = self.get_profile(&did, &actual_pds).await?;
|
|
|
|
println!("✅ Profile fetched: {} ({})", profile.display_name.as_deref().unwrap_or(&profile.handle), profile.did);
|
|
|
|
Ok(profile)
|
|
}
|
|
|
|
/// Generate profile URL for a given DID and PDS
|
|
pub fn generate_profile_url(did: &str, pds: &str) -> String {
|
|
let network_config = Self::get_network_config(pds);
|
|
match pds {
|
|
"syu.is" => format!("https://syu.is/profile/{}", did),
|
|
_ => format!("{}/profile/{}", network_config.web_url, did),
|
|
}
|
|
}
|
|
|
|
/// Convert Profile to JSON format used by the application
|
|
pub fn profile_to_json(&self, profile: &Profile, _pds: &str) -> Value {
|
|
serde_json::json!({
|
|
"did": profile.did,
|
|
"handle": profile.handle,
|
|
"displayName": profile.display_name.as_deref().unwrap_or(&profile.handle),
|
|
"avatar": profile.avatar.as_deref().unwrap_or(&format!("https://bsky.syu.is/img/avatar/plain/{}/default@jpeg", profile.did))
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Default for ProfileFetcher {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_network_config() {
|
|
let config = ProfileFetcher::get_network_config("syu.is");
|
|
assert_eq!(config.pds_api, "https://syu.is");
|
|
assert_eq!(config.bsky_api, "https://bsky.syu.is");
|
|
|
|
let config = ProfileFetcher::get_network_config("bsky.social");
|
|
assert_eq!(config.pds_api, "https://bsky.social");
|
|
assert_eq!(config.bsky_api, "https://public.api.bsky.app");
|
|
}
|
|
|
|
#[test]
|
|
fn test_profile_url_generation() {
|
|
let did = "did:plc:test123";
|
|
|
|
let url = ProfileFetcher::generate_profile_url(did, "syu.is");
|
|
assert_eq!(url, "https://syu.is/profile/did:plc:test123");
|
|
|
|
let url = ProfileFetcher::generate_profile_url(did, "bsky.social");
|
|
assert_eq!(url, "https://bsky.app/profile/did:plc:test123");
|
|
}
|
|
} |