1
0

feat: resolve profiles from appview for display name/handle/avatar

This commit is contained in:
2026-03-22 16:38:11 +09:00
parent 2f96ae9fe3
commit 770ed8ca8d
2 changed files with 85 additions and 22 deletions

View File

@@ -16,3 +16,4 @@ tracing = "0.1"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
base64 = "0.22" base64 = "0.22"
urlencoding = "2" urlencoding = "2"
reqwest = { version = "0.12", features = ["json"] }

View File

@@ -274,7 +274,7 @@ struct ErrorResp {
// --- App State --- // --- App State ---
struct AppState { struct AppState {
db: Mutex<Connection>, db: Arc<Mutex<Connection>>,
} }
// --- Auth --- // --- Auth ---
@@ -398,15 +398,54 @@ fn init_db(conn: &Connection) {
} }
fn ensure_account(conn: &Connection, did: &str) { fn ensure_account(conn: &Connection, did: &str) {
// handle must be a valid handle format, not a DID let fallback_handle = format!("{}.chat.invalid", did.split(':').last().unwrap_or("unknown"));
let handle = format!("{}.chat.invalid", did.split(':').last().unwrap_or("unknown"));
conn.execute( conn.execute(
"INSERT OR IGNORE INTO accounts (did, handle, created_at) VALUES (?1, ?2, ?3)", "INSERT OR IGNORE INTO accounts (did, handle, created_at) VALUES (?1, ?2, ?3)",
rusqlite::params![did, handle, now_iso()], rusqlite::params![did, fallback_handle, now_iso()],
) )
.ok(); .ok();
} }
/// Resolve profile from PDS/appview and update account in DB
async fn resolve_and_update_account(db: Arc<Mutex<Connection>>, did: String) {
// Skip if already resolved (handle doesn't end with .chat.invalid)
{
let conn = db.lock().unwrap();
let handle: Option<String> = conn.query_row(
"SELECT handle FROM accounts WHERE did = ?1",
rusqlite::params![did],
|row| row.get(0),
).ok();
if let Some(h) = handle {
if !h.ends_with(".chat.invalid") {
return;
}
}
}
let appview_url = std::env::var("APPVIEW_URL")
.unwrap_or_else(|_| "https://bsky.syu.is".into());
let url = format!(
"{}/xrpc/app.bsky.actor.getProfile?actor={}",
appview_url, did
);
let Ok(resp) = reqwest::get(&url).await else { return };
let Ok(profile) = resp.json::<serde_json::Value>().await else { return };
let handle = profile.get("handle").and_then(|v| v.as_str()).unwrap_or_default();
let display_name = profile.get("displayName").and_then(|v| v.as_str());
let avatar = profile.get("avatar").and_then(|v| v.as_str());
if !handle.is_empty() {
let conn = db.lock().unwrap();
conn.execute(
"UPDATE accounts SET handle = ?1, display_name = ?2, avatar = ?3 WHERE did = ?4",
rusqlite::params![handle, display_name, avatar, did],
).ok();
}
}
fn get_or_create_convo(conn: &Connection, members: &[String]) -> ConvoView { fn get_or_create_convo(conn: &Connection, members: &[String]) -> ConvoView {
let mut sorted = members.to_vec(); let mut sorted = members.to_vec();
sorted.sort(); sorted.sort();
@@ -779,10 +818,21 @@ async fn get_convo_for_members(
message: "Caller must be a member".into(), message: "Caller must be a member".into(),
}))); })));
} }
let db = state.db.lock().unwrap(); {
for m in &members { let db = state.db.lock().unwrap();
ensure_account(&db, m); for m in &members {
ensure_account(&db, m);
}
} }
// Resolve profiles in background
for m in &members {
let db = state.db.clone();
let did = m.clone();
tokio::spawn(async move {
resolve_and_update_account(db, did).await;
});
}
let db = state.db.lock().unwrap();
let convo = get_or_create_convo(&db, &members); let convo = get_or_create_convo(&db, &members);
Ok(Json(GetConvoResp { convo })) Ok(Json(GetConvoResp { convo }))
} }
@@ -811,23 +861,35 @@ async fn list_convos(
Query(params): Query<ListConvosParams>, Query(params): Query<ListConvosParams>,
) -> Result<Json<ListConvosResp>, (StatusCode, Json<ErrorResp>)> { ) -> Result<Json<ListConvosResp>, (StatusCode, Json<ErrorResp>)> {
let did = require_auth(&headers)?; let did = require_auth(&headers)?;
let db = state.db.lock().unwrap(); let (convos, member_dids) = {
let limit = params.limit.unwrap_or(20).min(100); let db = state.db.lock().unwrap();
let status_filter = params.status.unwrap_or_else(|| "accepted".into()); let limit = params.limit.unwrap_or(20).min(100);
let status_filter = params.status.unwrap_or_else(|| "accepted".into());
let mut stmt = db.prepare( let mut stmt = db.prepare(
"SELECT cm.convo_id FROM convo_members cm "SELECT cm.convo_id FROM convo_members cm
JOIN convos c ON c.id = cm.convo_id JOIN convos c ON c.id = cm.convo_id
WHERE cm.did = ?1 AND cm.status = ?2 WHERE cm.did = ?1 AND cm.status = ?2
ORDER BY c.updated_at DESC LIMIT ?3", ORDER BY c.updated_at DESC LIMIT ?3",
).unwrap(); ).unwrap();
let convo_ids: Vec<String> = stmt let convo_ids: Vec<String> = stmt
.query_map(rusqlite::params![did, status_filter, limit], |row| row.get(0)) .query_map(rusqlite::params![did, status_filter, limit], |row| row.get(0))
.unwrap().filter_map(|r| r.ok()).collect(); .unwrap().filter_map(|r| r.ok()).collect();
let convos: Vec<ConvoView> = convo_ids.iter() let convos: Vec<ConvoView> = convo_ids.iter()
.map(|id| load_convo(&db, id, Some(&did))).collect(); .map(|id| load_convo(&db, id, Some(&did))).collect();
let member_dids: Vec<String> = convos.iter()
.flat_map(|c| c.members.iter().map(|m| m.did.clone())).collect();
(convos, member_dids)
};
// Resolve profiles in background
for m in member_dids {
let db = state.db.clone();
tokio::spawn(async move { resolve_and_update_account(db, m).await; });
}
Ok(Json(ListConvosResp { cursor: None, convos })) Ok(Json(ListConvosResp { cursor: None, convos }))
} }
@@ -1134,7 +1196,7 @@ async fn main() {
let conn = Connection::open(&db_path).expect("Failed to open database"); let conn = Connection::open(&db_path).expect("Failed to open database");
init_db(&conn); init_db(&conn);
let state = Arc::new(AppState { db: Mutex::new(conn) }); let state = Arc::new(AppState { db: Arc::new(Mutex::new(conn)) });
let cors = CorsLayer::new() let cors = CorsLayer::new()
.allow_origin(Any) .allow_origin(Any)