Skip to main content

qualia_client_core/
social_connect.rs

1//! Connect codes, signed invites, and friend contacts for group chat.
2
3use std::fs;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use crate::state::Actor;
9use crate::user_profile::{load_profile, public_profile_card};
10
11const INVITE_TTL_SECS: u64 = 7 * 24 * 3600;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ConnectInvitePayload {
15    pub version: u8,
16    pub code: String,
17    pub inviter_name: String,
18    pub inviter_did: String,
19    pub inviter_pubkey_hex: String,
20    #[serde(default)]
21    pub relay_endpoint: String,
22    pub front_door_did: String,
23    pub profile_card: serde_json::Value,
24    pub created_at: u64,
25    pub expires_at: u64,
26    pub signature_hex: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ConnectInviteSummary {
31    pub code: String,
32    pub invite_json: String,
33    pub mailto_url: String,
34    pub inviter_did: String,
35    pub expires_at: u64,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ChatContact {
40    pub actor_id: String,
41    pub display_name: String,
42    pub did: String,
43    pub source: String,
44    pub added_at: u64,
45    #[serde(default)]
46    pub relay_endpoint: Option<String>,
47    /// Optional tags for ontology / torrent sharing filters (e.g. `health`, `research`).
48    #[serde(default)]
49    pub categories: Vec<String>,
50}
51
52fn contacts_path() -> PathBuf {
53    crate::state::app_meta_dir().join("chat_contacts.json")
54}
55
56fn unix_now() -> u64 {
57    std::time::SystemTime::now()
58        .duration_since(std::time::UNIX_EPOCH)
59        .unwrap_or_default()
60        .as_secs()
61}
62
63fn format_connect_code(raw: u64) -> String {
64    const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
65    let mut n = raw;
66    let mut chars = [0u8; 8];
67    for c in &mut chars {
68        *c = ALPHABET[(n % 32) as usize];
69        n /= 32;
70    }
71    format!(
72        "QUALIA-{}-{}",
73        std::str::from_utf8(&chars[0..4]).unwrap_or("XXXX"),
74        std::str::from_utf8(&chars[4..8]).unwrap_or("XXXX")
75    )
76}
77
78fn pct_encode(input: &str) -> String {
79    let mut out = String::new();
80    for b in input.bytes() {
81        match b {
82            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
83                out.push(b as char);
84            }
85            _ => out.push_str(&format!("%{b:02X}")),
86        }
87    }
88    out
89}
90
91fn resolve_front_door_did(profile: &crate::user_profile::UserProfile) -> String {
92    let state = match crate::state::APP_STATE.get() {
93        Some(s) => s,
94        None => return profile.public_did.clone(),
95    };
96    let doors = state.front_doors.lock().unwrap();
97    if let Some(ref fd_id) = profile.active_front_door_id {
98        if let Some(door) = doors.iter().find(|d| d.id == *fd_id) {
99            return door.did_uri.clone();
100        }
101    }
102    doors
103        .first()
104        .map(|d| d.did_uri.clone())
105        .unwrap_or_else(|| profile.public_did.clone())
106}
107
108pub fn generate_connect_invite(
109    front_door_id: Option<String>,
110) -> Result<ConnectInviteSummary, String> {
111    let profile = load_profile();
112    if !profile.sharing.allow_group_chat_invites {
113        return Err(
114            "Group chat invites are disabled in your profile sharing settings.".to_string(),
115        );
116    }
117
118    let state = crate::state::APP_STATE
119        .get()
120        .ok_or("APP_STATE not initialized")?;
121
122    let mut profile = profile;
123    if let Some(id) = front_door_id {
124        profile.active_front_door_id = Some(id);
125    }
126    profile.public_did = crate::user_profile::resolve_public_did(&profile);
127    let front_door_did = resolve_front_door_did(&profile);
128
129    let created = unix_now();
130    let expires = created + INVITE_TTL_SECS;
131    let code = format_connect_code(created ^ profile.public_did.len() as u64);
132
133    let vault = state.key_vault.lock().unwrap();
134    let signing_key = vault.derive_key("connect-invite");
135    let inviter_pubkey_hex =
136        hex::encode(ed25519_dalek::VerifyingKey::from(&signing_key).as_bytes());
137
138    let relay_endpoint = profile
139        .relay_base_url
140        .clone()
141        .filter(|s| !s.is_empty())
142        .unwrap_or_else(crate::chat_relay::local_relay_base_url);
143
144    let payload_unsigned = serde_json::json!({
145        "version": 1,
146        "code": code,
147        "inviter_name": profile.display_name,
148        "inviter_did": profile.public_did,
149        "inviter_pubkey_hex": inviter_pubkey_hex,
150        "relay_endpoint": relay_endpoint,
151        "front_door_did": front_door_did,
152        "profile_card": public_profile_card(&profile),
153        "created_at": created,
154        "expires_at": expires,
155    });
156
157    let payload_str = serde_json::to_string(&payload_unsigned).map_err(|e| e.to_string())?;
158    let sig = vault.sign_payload(&signing_key, payload_str.as_bytes());
159    let signature_hex = hex::encode(sig.to_bytes());
160    drop(vault);
161
162    let profile_card = public_profile_card(&profile);
163    let invite = ConnectInvitePayload {
164        version: 1,
165        code: code.clone(),
166        inviter_name: profile.display_name.clone(),
167        inviter_did: profile.public_did.clone(),
168        inviter_pubkey_hex,
169        relay_endpoint: relay_endpoint.clone(),
170        front_door_did,
171        profile_card,
172        created_at: created,
173        expires_at: expires,
174        signature_hex,
175    };
176
177    let invite_json = serde_json::to_string(&invite).map_err(|e| e.to_string())?;
178    let mailto = if profile.sharing.allow_email_invites {
179        let subject = pct_encode("Join my Qualia chat");
180        let body = pct_encode(&format!(
181            "Connect with me on Qualia.\n\nInvite code: {code}\n\nOr paste this invite JSON in Qualia → Profile → Add Friend:\n{invite_json}"
182        ));
183        format!("mailto:?subject={subject}&body={body}")
184    } else {
185        String::new()
186    };
187
188    Ok(ConnectInviteSummary {
189        code,
190        invite_json,
191        mailto_url: mailto,
192        inviter_did: invite.inviter_did,
193        expires_at: expires,
194    })
195}
196
197pub fn accept_connect_invite(input: &str) -> Result<ChatContact, String> {
198    let invite: ConnectInvitePayload = if input.trim().starts_with('{') {
199        serde_json::from_str(input.trim()).map_err(|e| format!("Invalid invite JSON: {e}"))?
200    } else {
201        return Err(
202            "Paste the full invite JSON from your friend (Profile → Share connect code). Short codes alone are not yet supported for remote lookup.".to_string(),
203        );
204    };
205
206    if unix_now() > invite.expires_at {
207        return Err("This connect invite has expired.".to_string());
208    }
209
210    if !invite.inviter_pubkey_hex.is_empty() && !invite.signature_hex.is_empty() {
211        let pk_bytes = hex::decode(&invite.inviter_pubkey_hex)
212            .map_err(|e| format!("Invalid invite public key: {e}"))?;
213        if pk_bytes.len() == 32 {
214            let sig_bytes = hex::decode(&invite.signature_hex)
215                .map_err(|e| format!("Invalid invite signature: {e}"))?;
216            if sig_bytes.len() == 64 {
217                let mut pk_arr = [0u8; 32];
218                pk_arr.copy_from_slice(&pk_bytes);
219                let mut sig_arr = [0u8; 64];
220                sig_arr.copy_from_slice(&sig_bytes);
221                let payload_unsigned = serde_json::json!({
222                    "version": invite.version,
223                    "code": invite.code,
224                    "inviter_name": invite.inviter_name,
225                    "inviter_did": invite.inviter_did,
226                    "inviter_pubkey_hex": invite.inviter_pubkey_hex,
227                    "relay_endpoint": invite.relay_endpoint,
228                    "front_door_did": invite.front_door_did,
229                    "profile_card": invite.profile_card,
230                    "created_at": invite.created_at,
231                    "expires_at": invite.expires_at,
232                });
233                let payload_str =
234                    serde_json::to_string(&payload_unsigned).map_err(|e| e.to_string())?;
235                if qualia_core_db::key_vault::KeyVault::verify_signature(
236                    &pk_arr,
237                    payload_str.as_bytes(),
238                    &sig_arr,
239                )
240                .is_err()
241                {
242                    return Err("Invite signature verification failed.".to_string());
243                }
244            }
245        }
246    }
247
248    let display_name = invite
249        .profile_card
250        .get("display_name")
251        .and_then(|v| v.as_str())
252        .unwrap_or(&invite.inviter_name)
253        .to_string();
254
255    let short_id = invite
256        .inviter_did
257        .rsplit(':')
258        .next()
259        .unwrap_or("peer")
260        .chars()
261        .take(12)
262        .collect::<String>();
263    let actor = Actor {
264        id: format!("contact-{short_id}"),
265        actor_type: "FRIEND".to_string(),
266        name: display_name.clone(),
267        organization: None,
268        qualifications: vec![],
269        roles: vec!["chat_participant".to_string()],
270        verification_status: "INVITE_ACCEPTED".to_string(),
271        pairwise_did: invite.inviter_did.clone(),
272        root_did_uri: Some(invite.front_door_did.clone()),
273        routing_hints: vec![],
274    };
275
276    crate::api::add_directory_actor(actor)?;
277
278    let contact = ChatContact {
279        actor_id: format!("contact-{short_id}"),
280        display_name,
281        did: invite.inviter_did,
282        source: format!("connect:{}", invite.code),
283        added_at: unix_now(),
284        relay_endpoint: if invite.relay_endpoint.is_empty() {
285            None
286        } else {
287            Some(invite.relay_endpoint)
288        },
289        categories: vec![],
290    };
291
292    let mut contacts = load_contacts();
293    contacts.retain(|c| c.did != contact.did);
294    contacts.push(contact.clone());
295    save_contacts(&contacts)?;
296
297    Ok(contact)
298}
299
300pub fn load_contacts() -> Vec<ChatContact> {
301    let path = contacts_path();
302    fs::read_to_string(path)
303        .ok()
304        .and_then(|t| serde_json::from_str(&t).ok())
305        .unwrap_or_default()
306}
307
308fn save_contacts(contacts: &[ChatContact]) -> Result<(), String> {
309    let path = contacts_path();
310    if let Some(parent) = path.parent() {
311        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
312    }
313    let text = serde_json::to_string_pretty(contacts).map_err(|e| e.to_string())?;
314    fs::write(path, text).map_err(|e| e.to_string())
315}
316
317pub fn find_contact_by_did(did: &str) -> Option<ChatContact> {
318    list_chat_contacts().into_iter().find(|c| c.did == did)
319}
320
321pub fn update_contact_categories(
322    did: &str,
323    categories: Vec<String>,
324) -> Result<ChatContact, String> {
325    let mut contacts = load_contacts();
326    let idx = contacts
327        .iter()
328        .position(|c| c.did == did)
329        .ok_or_else(|| format!("Contact not found: {did}"))?;
330    contacts[idx].categories = categories
331        .into_iter()
332        .map(|s| s.trim().to_string())
333        .filter(|s| !s.is_empty())
334        .collect();
335    save_contacts(&contacts)?;
336    Ok(contacts[idx].clone())
337}
338
339pub fn list_chat_contacts() -> Vec<ChatContact> {
340    let mut contacts = load_contacts();
341    if contacts.is_empty() {
342        if let Ok(actors) = crate::api::get_directory_actors() {
343            contacts = actors
344                .into_iter()
345                .filter(|a| {
346                    a.actor_type == "FRIEND" || a.roles.iter().any(|r| r == "chat_participant")
347                })
348                .map(|a| ChatContact {
349                    actor_id: a.id,
350                    display_name: a.name,
351                    did: a.pairwise_did,
352                    source: "directory".to_string(),
353                    added_at: unix_now(),
354                    relay_endpoint: None,
355                    categories: vec![],
356                })
357                .collect();
358        }
359    }
360    contacts
361}