···66[dependencies]
77anyhow = "1.0.99"
88async-trait = "0.1.89"
99+lazy_static = "1.4.0"
1010+moka = { version = "0.12", features = ["future"] }
911reqwest = { version = "0.12.23", features = ["json"] }
1012rocketman = "0.2.5"
1313+serde = { version = "1.0", features = ["derive"] }
1114serde_json = "1.0.145"
1215dotenv = "0.15.0"
1316tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+18-1
src/main.rs
···1111 sync::{Arc, Mutex},
1212};
13131414+mod resolve;
1515+1416#[tokio::main]
1517async fn main() {
1618 // Load environment variables from .env file
···8284 let client = Client::new();
8385 let url = std::env::var("DISCORD_WEBHOOK_URL")
8486 .expect("DISCORD_WEBHOOK_URL environment variable must be set");
8787+8888+ // Get resolver app view URL from environment
8989+ let resolver_app_view = std::env::var("RESOLVER_APP_VIEW")
9090+ .unwrap_or_else(|_| "https://bsky.social".to_string());
9191+8592 // Safely extract track name and artist from the record
8693 let track_info = message
8794 .commit
···95102 })
96103 .unwrap_or_else(|| "unknown track".to_string());
97104105105+ // Resolve the handle from the DID
106106+ let handle = match resolve::resolve_identity(&message.did, &resolver_app_view).await {
107107+ Ok(resolved) => resolved.identity,
108108+ Err(e) => {
109109+ eprintln!("Failed to resolve handle for DID {}: {}", message.did, e);
110110+ // Fallback to showing the DID if resolution fails
111111+ message.did.clone()
112112+ }
113113+ };
114114+98115 let payload = json!({
9999- "content": format!("{} is listening to {}", message.did, track_info)
116116+ "content": format!("{} is listening to {}", handle, track_info)
100117 });
101118 let response = client.post(url).json(&payload).send().await?;
102119
+295
src/resolve.rs
···11+// parts rewritten from https://github.com/mary-ext/atcute/blob/trunk/packages/oauth/browser-client/
22+// from https://github.com/espeon/geranium/blob/main/src/resolve.rs
33+// MIT License
44+55+use lazy_static::lazy_static;
66+use moka::future::Cache;
77+use serde::{Deserialize, Serialize};
88+use std::time::Duration;
99+1010+// Cache for handle resolution - maps handle to DID
1111+type HandleCache = Cache<String, String>;
1212+1313+// Cache for DID documents - maps DID to DidDocument
1414+type DidDocumentCache = Cache<String, DidDocument>;
1515+1616+// Global cache instances
1717+lazy_static::lazy_static! {
1818+ static ref HANDLE_CACHE: HandleCache = Cache::builder()
1919+ .time_to_live(Duration::from_secs(3600)) // 1 hour TTL
2020+ .max_capacity(10000)
2121+ .build();
2222+2323+ static ref DID_DOCUMENT_CACHE: DidDocumentCache = Cache::builder()
2424+ .time_to_live(Duration::from_secs(3600)) // 1 hour TTL
2525+ .max_capacity(10000)
2626+ .build();
2727+}
2828+2929+// should be same as regex /^did:[a-z]+:[\S\s]+/
3030+fn is_did(did: &str) -> bool {
3131+ let parts: Vec<&str> = did.split(':').collect();
3232+3333+ if parts.len() != 3 {
3434+ // must have exactly 3 parts: "did", method, and identifier
3535+ return false;
3636+ }
3737+3838+ if parts[0] != "did" {
3939+ // first part must be "did"
4040+ return false;
4141+ }
4242+4343+ if !parts[1].chars().all(|c| c.is_ascii_lowercase()) {
4444+ // method must be all lowercase
4545+ return false;
4646+ }
4747+4848+ if parts[2].is_empty() {
4949+ // identifier can't be empty
5050+ return false;
5151+ }
5252+5353+ true
5454+}
5555+5656+fn is_valid_domain(domain: &str) -> bool {
5757+ // Check if empty or too long
5858+ if domain.is_empty() || domain.len() > 253 {
5959+ return false;
6060+ }
6161+6262+ // Split into labels
6363+ let labels: Vec<&str> = domain.split('.').collect();
6464+6565+ // Must have at least 2 labels
6666+ if labels.len() < 2 {
6767+ return false;
6868+ }
6969+7070+ // Check each label
7171+ for label in labels {
7272+ // Label length check
7373+ if label.is_empty() || label.len() > 63 {
7474+ return false;
7575+ }
7676+7777+ // Must not start or end with hyphen
7878+ if label.starts_with('-') || label.ends_with('-') {
7979+ return false;
8080+ }
8181+8282+ // Check characters
8383+ if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
8484+ return false;
8585+ }
8686+ }
8787+8888+ true
8989+}
9090+9191+async fn resolve_handle(handle: &str, resolver_app_view: &str) -> Result<String, reqwest::Error> {
9292+ // Check cache first
9393+ if let Some(cached_did) = HANDLE_CACHE.get(handle).await {
9494+ println!("🎯 Cache HIT for handle: {} -> {}", handle, cached_did);
9595+ return Ok(cached_did);
9696+ }
9797+9898+ println!("❌ Cache MISS for handle: {}, resolving from API", handle);
9999+100100+ // If not in cache, resolve from API
101101+ let res = reqwest::get(format!(
102102+ "{}/xrpc/com.atproto.identity.resolveHandle?handle={}",
103103+ resolver_app_view, handle
104104+ ))
105105+ .await?
106106+ .json::<ResolvedHandle>()
107107+ .await?;
108108+109109+ let did = res.did;
110110+111111+ // Cache the result
112112+ HANDLE_CACHE.insert(handle.to_string(), did.clone()).await;
113113+ println!("💾 Cached handle resolution: {} -> {}", handle, did);
114114+115115+ Ok(did)
116116+}
117117+118118+async fn get_did_doc(did: &str) -> Result<DidDocument, reqwest::Error> {
119119+ // Check cache first
120120+ if let Some(cached_doc) = DID_DOCUMENT_CACHE.get(did).await {
121121+ println!("🎯 Cache HIT for DID document: {}", did);
122122+ return Ok(cached_doc);
123123+ }
124124+125125+ println!("❌ Cache MISS for DID document: {}, resolving from API", did);
126126+127127+ // If not in cache, resolve from API
128128+ // get the specific did spec
129129+ // did:plc:abcd1e -> plc
130130+ let parts: Vec<&str> = did.split(':').collect();
131131+ let spec = parts[1];
132132+ let doc = match spec {
133133+ "plc" => {
134134+ println!("📡 Fetching DID document from PLC directory for: {}", did);
135135+ let res: DidDocument = reqwest::get(format!("https://plc.directory/{}", did))
136136+ .await?
137137+ .error_for_status()?
138138+ .json()
139139+ .await?;
140140+ res
141141+ }
142142+ "web" => {
143143+ if !is_valid_domain(parts[2]) {
144144+ todo!("Error for domain in did:web is not valid");
145145+ };
146146+ let ident = parts[2];
147147+ println!("📡 Fetching DID document from web domain: {}", ident);
148148+ let res = reqwest::get(format!("https://{}/.well-known/did.json", ident))
149149+ .await?
150150+ .error_for_status()?
151151+ .json()
152152+ .await?;
153153+ res
154154+ }
155155+ _ => todo!("Identifier not supported"),
156156+ };
157157+158158+ // Cache the result
159159+ DID_DOCUMENT_CACHE.insert(did.to_string(), doc.clone()).await;
160160+ println!("💾 Cached DID document: {}", did);
161161+162162+ Ok(doc)
163163+}
164164+165165+fn get_pds_endpoint(doc: &DidDocument) -> Option<DidDocumentService> {
166166+ get_service_endpoint(doc, "#atproto_pds", "AtprotoPersonalDataServer")
167167+}
168168+169169+fn get_service_endpoint(
170170+ doc: &DidDocument,
171171+ svc_id: &str,
172172+ svc_type: &str,
173173+) -> Option<DidDocumentService> {
174174+ doc.service
175175+ .iter()
176176+ .find(|svc| svc.id == svc_id && svc._type == svc_type)
177177+ .cloned()
178178+}
179179+180180+fn extract_handle_from_doc(doc: &DidDocument) -> Option<String> {
181181+ // Look through alsoKnownAs list for at:// URLs
182182+ for also_known_as in &doc.also_known_as {
183183+ if also_known_as.starts_with("at://") {
184184+ // Extract handle from "at://handle.domain" format
185185+ let handle = also_known_as.strip_prefix("at://")?;
186186+ println!("🎯 Found handle in alsoKnownAs: {} -> {}", also_known_as, handle);
187187+ return Some(handle.to_string());
188188+ }
189189+ }
190190+ None
191191+}
192192+193193+pub async fn resolve_identity(
194194+ id: &str,
195195+ resolver_app_view: &str,
196196+) -> Result<ResolvedIdentity, reqwest::Error> {
197197+ println!("🔍 Resolving identity: {}", id);
198198+199199+ // is our identifier a did
200200+ let did = if is_did(id) {
201201+ println!("✅ Input is already a DID: {}", id);
202202+ id
203203+ } else {
204204+ println!("🔗 Input is a handle, resolving to DID: {}", id);
205205+ // our id must be either invalid or a handle
206206+ if let Ok(res) = resolve_handle(id, resolver_app_view).await {
207207+ &res.clone()
208208+ } else {
209209+ todo!("Error type for could not resolve handle")
210210+ }
211211+ };
212212+213213+ let doc = get_did_doc(did).await?;
214214+ let pds = get_pds_endpoint(&doc);
215215+216216+ if pds.is_none() {
217217+ todo!("Error for could not find PDS")
218218+ }
219219+220220+ // Extract handle from alsoKnownAs list
221221+ let handle = extract_handle_from_doc(&doc).unwrap_or_else(|| {
222222+ println!("⚠️ No handle found in alsoKnownAs, using original input: {}", id);
223223+ id.to_string()
224224+ });
225225+226226+ println!("✅ Successfully resolved identity: {} -> {} (handle: {}) (PDS: {})",
227227+ id, did, handle, pds.as_ref().unwrap().service_endpoint);
228228+229229+ return Ok(ResolvedIdentity {
230230+ did: did.to_owned(),
231231+ doc,
232232+ identity: handle,
233233+ pds: pds.unwrap().service_endpoint,
234234+ });
235235+}
236236+237237+/// Clear all cached handle resolutions and DID documents
238238+pub async fn clear_cache() {
239239+ HANDLE_CACHE.invalidate_all();
240240+ DID_DOCUMENT_CACHE.invalidate_all();
241241+}
242242+243243+/// Get cache statistics for monitoring
244244+pub async fn get_cache_stats() -> (u64, u64) {
245245+ let handle_count = HANDLE_CACHE.entry_count();
246246+ let did_doc_count = DID_DOCUMENT_CACHE.entry_count();
247247+ (handle_count, did_doc_count)
248248+}
249249+250250+// want this to be reusable on case of scope expansion :(
251251+#[allow(dead_code)]
252252+#[derive(Serialize, Deserialize, Debug)]
253253+pub struct ResolvedIdentity {
254254+ pub did: String,
255255+ pub doc: DidDocument,
256256+ pub identity: String,
257257+ // should prob be url type but not really needed rn
258258+ pub pds: String,
259259+}
260260+261261+#[derive(Serialize, Deserialize, Debug)]
262262+struct ResolvedHandle {
263263+ did: String,
264264+}
265265+266266+#[derive(Serialize, Deserialize, Debug, Clone)]
267267+pub struct DidDocument {
268268+ #[serde(alias = "@context")]
269269+ pub _context: Vec<String>,
270270+ pub id: String,
271271+ #[serde(alias = "alsoKnownAs")]
272272+ pub also_known_as: Vec<String>,
273273+ #[serde(alias = "verificationMethod")]
274274+ pub verification_method: Vec<DidDocumentVerificationMethod>,
275275+ pub service: Vec<DidDocumentService>,
276276+}
277277+278278+#[derive(Serialize, Deserialize, Debug, Clone)]
279279+pub struct DidDocumentVerificationMethod {
280280+ pub id: String,
281281+ #[serde(alias = "type")]
282282+ pub _type: String,
283283+ pub controller: String,
284284+ #[serde(alias = "publicKeyMultibase")]
285285+ pub public_key_multibase: String,
286286+}
287287+288288+#[derive(Serialize, Deserialize, Debug, Clone)]
289289+pub struct DidDocumentService {
290290+ pub id: String,
291291+ #[serde(alias = "type")]
292292+ pub _type: String,
293293+ #[serde(alias = "serviceEndpoint")]
294294+ pub service_endpoint: String,
295295+}