1use 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 #[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}