1use crate::enums::EducationAttainedProfile;
2use crate::errors::HingeError;
3use crate::logging::{log_request, log_response};
4use crate::models::{
5 AccountInfo, AnswerContentPayload, AnswerEvaluateRequest, AuthSettings, ConnectionContentItem,
6 ConnectionDetailApi, ConnectionItem, ConnectionsResponse, CreatePromptPollRequest,
7 CreatePromptPollResponse, CreateRate, CreateRateContent, CreateRateContentPrompt,
8 CreateVideoPromptRequest, CreateVideoPromptResponse, ExportChatInput, ExportChatResult,
9 ExportStatus, ExportedMediaFile, HingeAuthToken, LikeLimit, LikeResponse, LikesV2Response,
10 LoginTokens, MatchNoteResponse, NotificationSettings, PhotoAsset, PhotoAssetInput, Preferences,
11 PreferencesResponse, ProfileContentFull, ProfileUpdate, Prompt, PromptsResponse,
12 PublicUserProfile, RateInput, RateRespondRequest, RateRespondResponse, RecommendationSubject,
13 RecommendationsResponse, SelfContentResponse, SelfProfileResponse, SendbirdAuthToken,
14 SendbirdChannelHandle, SendbirdGroupChannel, SendbirdMessage, SkipInput, StandoutsResponse,
15 UserSettings, UserTrait,
16};
17use crate::prompts_manager::HingePromptsManager;
18use crate::settings::Settings;
19use crate::storage::{SecretStore, Storage};
20use chrono::{DateTime, Local, Utc};
21use futures_util::{SinkExt, StreamExt};
22use reqwest::{Client as Http, StatusCode};
23use serde_json::json;
24use std::cmp::min;
25use std::collections::{HashMap, HashSet};
26use std::fmt::Write as FmtWrite;
27use std::fs;
28use std::path::Path;
29use std::path::PathBuf;
30use std::time::{Duration, Instant};
31use tokio::time::sleep;
32use tokio_tungstenite::tungstenite::Message;
33use uuid::Uuid;
34
35pub const DEFAULT_PUBLIC_IDS_BATCH_SIZE: usize = 75;
36
37#[derive(Clone, Debug)]
38pub struct RecsFetchConfig {
39 pub multi_fetch_count: usize,
40 pub request_delay_ms: u64,
41 pub rate_limit_retries: usize,
42 pub rate_limit_backoff_ms: u64,
43}
44
45impl Default for RecsFetchConfig {
46 fn default() -> Self {
47 Self {
48 multi_fetch_count: 3,
49 request_delay_ms: 1_500,
50 rate_limit_retries: 3,
51 rate_limit_backoff_ms: 4_000,
52 }
53 }
54}
55
56fn profile_update_to_api_json(update: &ProfileUpdate) -> serde_json::Value {
58 use crate::enums::ApiEnum;
59
60 let mut obj = serde_json::Map::new();
61
62 if let Some(ref children) = update.children {
64 obj.insert(
65 "children".to_string(),
66 json!({
67 "value": children.value.to_api_value(),
68 "visible": children.visible
69 }),
70 );
71 }
72
73 if let Some(ref dating) = update.dating_intention {
74 obj.insert(
75 "datingIntention".to_string(),
76 json!({
77 "value": dating.value.to_api_value(),
78 "visible": dating.visible
79 }),
80 );
81 }
82
83 if let Some(ref drinking) = update.drinking {
84 obj.insert(
85 "drinking".to_string(),
86 json!({
87 "value": drinking.value.to_api_value(),
88 "visible": drinking.visible
89 }),
90 );
91 }
92
93 if let Some(ref drugs) = update.drugs {
94 obj.insert(
95 "drugs".to_string(),
96 json!({
97 "value": drugs.value.to_api_value(),
98 "visible": drugs.visible
99 }),
100 );
101 }
102
103 if let Some(ref marijuana) = update.marijuana {
104 obj.insert(
105 "marijuana".to_string(),
106 json!({
107 "value": marijuana.value.to_api_value(),
108 "visible": marijuana.visible
109 }),
110 );
111 }
112
113 if let Some(ref smoking) = update.smoking {
114 obj.insert(
115 "smoking".to_string(),
116 json!({
117 "value": smoking.value.to_api_value(),
118 "visible": smoking.visible
119 }),
120 );
121 }
122
123 if let Some(ref politics) = update.politics {
124 obj.insert(
125 "politics".to_string(),
126 json!({
127 "value": politics.value.to_api_value(),
128 "visible": politics.visible
129 }),
130 );
131 }
132
133 if let Some(ref religions) = update.religions {
134 let values: Vec<i8> = religions.value.iter().map(|e| e.to_api_value()).collect();
135 obj.insert(
136 "religions".to_string(),
137 json!({
138 "value": values,
139 "visible": religions.visible
140 }),
141 );
142 }
143
144 if let Some(ref ethnicities) = update.ethnicities {
145 let values: Vec<i8> = ethnicities.value.iter().map(|e| e.to_api_value()).collect();
146 obj.insert(
147 "ethnicities".to_string(),
148 json!({
149 "value": values,
150 "visible": ethnicities.visible
151 }),
152 );
153 }
154
155 if let Some(ref education) = update.education_attained {
156 obj.insert(
157 "educationAttained".to_string(),
158 json!(education.to_api_value()),
159 );
160 }
161
162 if let Some(ref relationships) = update.relationship_type_ids {
163 let values: Vec<i8> = relationships
164 .value
165 .iter()
166 .map(|e| e.to_api_value())
167 .collect();
168 obj.insert(
169 "relationshipTypeIds".to_string(),
170 json!({
171 "value": values,
172 "visible": relationships.visible
173 }),
174 );
175 }
176
177 if let Some(height) = update.height {
178 obj.insert("height".to_string(), json!(height));
179 }
180
181 if let Some(ref gender) = update.gender_id {
182 obj.insert("genderId".to_string(), json!(gender.to_api_value()));
183 }
184
185 if let Some(ref hometown) = update.hometown {
186 obj.insert(
187 "hometown".to_string(),
188 json!({
189 "value": hometown.value,
190 "visible": hometown.visible
191 }),
192 );
193 }
194
195 if let Some(ref languages) = update.languages_spoken {
196 obj.insert(
197 "languagesSpoken".to_string(),
198 json!({
199 "value": languages.value,
200 "visible": languages.visible
201 }),
202 );
203 }
204
205 if let Some(ref zodiac) = update.zodiac {
206 obj.insert(
207 "zodiac".to_string(),
208 json!({
209 "value": zodiac.value,
210 "visible": zodiac.visible
211 }),
212 );
213 }
214
215 serde_json::Value::Object(obj)
216}
217
218fn preferences_to_api_json(prefs: &Preferences) -> serde_json::Value {
220 use crate::enums::ApiEnum;
221
222 json!({
223 "genderedAgeRanges": prefs.gendered_age_ranges,
224 "dealbreakers": prefs.dealbreakers,
225 "religions": prefs.religions.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
226 "drinking": prefs.drinking.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
227 "genderedHeightRanges": prefs.gendered_height_ranges,
228 "marijuana": prefs.marijuana.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
229 "relationshipTypes": prefs.relationship_types.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
230 "drugs": prefs.drugs.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
231 "maxDistance": prefs.max_distance,
232 "children": prefs.children.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
233 "ethnicities": prefs.ethnicities.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
234 "smoking": prefs.smoking.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
235 "educationAttained": prefs.education_attained.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
236 "familyPlans": prefs.family_plans,
237 "datingIntentions": prefs.dating_intentions.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
238 "politics": prefs.politics.iter().map(|e| e.to_api_value()).collect::<Vec<_>>(),
239 "genderPreferences": prefs.gender_preferences.iter().map(|e| e.to_api_value()).collect::<Vec<_>>()
240 })
241}
242
243const CHILDREN_LABELS: &[(i32, &str)] = &[
244 (-1, "Open to all"),
245 (0, "Prefer not to say"),
246 (1, "Don't have children"),
247 (2, "Have children"),
248];
249
250const DATING_LABELS: &[(i32, &str)] = &[
251 (-1, "Open to all"),
252 (0, "Unknown"),
253 (1, "Life partner"),
254 (2, "Long-term relationship"),
255 (3, "Long-term, open to short"),
256 (4, "Short-term, open to long"),
257 (5, "Short-term relationship"),
258 (6, "Figuring out their dating goals"),
259];
260
261const DRINKING_LABELS: &[(i32, &str)] = &[
262 (-1, "Open to all"),
263 (0, "Prefer not to say"),
264 (1, "Don't drink"),
265 (2, "Drink"),
266 (3, "Sometimes"),
267];
268
269const SMOKING_LABELS: &[(i32, &str)] = &[
270 (-1, "Open to all"),
271 (0, "Prefer not to say"),
272 (1, "Don't smoke"),
273 (2, "Smoke"),
274 (3, "Sometimes"),
275];
276
277const MARIJUANA_LABELS: &[(i32, &str)] = &[
278 (-1, "Open to all"),
279 (0, "Prefer not to say"),
280 (1, "Don't use marijuana"),
281 (2, "Use marijuana"),
282 (3, "Sometimes"),
283 (4, "No preference"),
284];
285
286const DRUG_LABELS: &[(i32, &str)] = &[
287 (-1, "Open to all"),
288 (0, "Prefer not to say"),
289 (1, "Don't use drugs"),
290 (2, "Use drugs"),
291 (3, "Sometimes"),
292];
293
294const RELATIONSHIP_TYPE_LABELS: &[(i32, &str)] = &[
295 (-1, "Open to all"),
296 (1, "Monogamy"),
297 (2, "Ethical non-monogamy"),
298 (3, "Open relationship"),
299 (4, "Polyamory"),
300 (5, "Open to exploring"),
301];
302
303fn label_from_map(map: &'static [(i32, &'static str)], code: Option<i32>) -> Option<&'static str> {
304 let key = code?;
305 map.iter().find(|(c, _)| *c == key).map(|(_, label)| *label)
306}
307
308fn labels_from_map(
309 map: &'static [(i32, &'static str)],
310 codes: &Option<Vec<i32>>,
311) -> Vec<&'static str> {
312 match codes {
313 Some(values) => values
314 .iter()
315 .filter_map(|code| map.iter().find(|(c, _)| c == code).map(|(_, label)| *label))
316 .collect(),
317 None => Vec::new(),
318 }
319}
320
321fn sanitize_component(input: &str) -> String {
322 let trimmed = input.trim();
323 let mut out = String::with_capacity(trimmed.len());
324 for ch in trimmed.chars() {
325 if matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') {
326 out.push('_');
327 } else {
328 out.push(ch);
329 }
330 }
331 if out.is_empty() { "export".into() } else { out }
332}
333
334fn parse_ts(value: &str) -> Option<i64> {
335 value.parse::<i64>().ok()
336}
337
338fn parse_json_with_path<T: serde::de::DeserializeOwned>(text: &str) -> Result<T, HingeError> {
339 let mut deserializer = serde_json::Deserializer::from_str(text);
340 serde_path_to_error::deserialize(&mut deserializer).map_err(|err| {
341 let path = err.path().to_string();
342 if path == "." {
343 HingeError::Serde(err.inner().to_string())
344 } else {
345 HingeError::Serde(format!("{} at {}", err.inner(), path))
346 }
347 })
348}
349
350fn parse_json_value_with_path<T: serde::de::DeserializeOwned>(
351 value: serde_json::Value,
352) -> Result<T, HingeError> {
353 parse_json_with_path(&value.to_string())
354}
355
356fn attachment_from_value(value: &serde_json::Value) -> Option<(String, String)> {
357 if !value.is_object() {
358 return None;
359 }
360 let url = value
361 .get("url")
362 .and_then(|v| v.as_str())
363 .or_else(|| value.get("secure_url").and_then(|v| v.as_str()))?;
364 let name = value
365 .get("name")
366 .and_then(|v| v.as_str())
367 .map(|s| s.to_string())
368 .unwrap_or_else(|| {
369 url.split('/')
370 .next_back()
371 .unwrap_or("attachment")
372 .to_string()
373 });
374 Some((url.to_string(), name))
375}
376
377fn education_attained_label(value: &EducationAttainedProfile) -> &'static str {
378 use EducationAttainedProfile::*;
379 match value {
380 PreferNotToSay => "Prefer not to say",
381 HighSchool => "High school",
382 TradeSchool => "Trade school",
383 InCollege => "In college",
384 Undergraduate => "Undergraduate degree",
385 InGradSchool => "In grad school",
386 Graduate => "Graduate degree",
387 }
388}
389
390#[derive(Clone)]
391pub struct HingeClient<S: Storage + Clone> {
392 http: Http,
393 pub settings: Settings,
394 pub storage: S,
395 secret_store: Option<std::sync::Arc<dyn SecretStore>>,
396 pub phone_number: String,
397 pub device_id: String,
398 pub install_id: String,
399 pub session_id: String,
400 pub installed: bool,
401 pub hinge_auth: Option<HingeAuthToken>,
402 pub sendbird_auth: Option<SendbirdAuthToken>,
403 pub sendbird_session_key: Option<String>,
404 sendbird_ws_cmd_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>, sendbird_ws_broadcast_tx: Option<tokio::sync::broadcast::Sender<String>>, sendbird_ws_connected: bool,
408 sendbird_ws_pending_requests: std::sync::Arc<
409 tokio::sync::Mutex<
410 std::collections::HashMap<String, tokio::sync::oneshot::Sender<serde_json::Value>>,
411 >,
412 >,
413 pub recommendations: std::collections::HashMap<String, RecommendationSubject>,
414 session_path: Option<String>,
416 cache_dir: Option<PathBuf>,
417 auto_persist: bool,
418 recs_fetch_config: RecsFetchConfig,
419 public_ids_batch_size: usize,
420 last_recs_v2_call: Option<Instant>,
421}
422
423impl<S: Storage + Clone> HingeClient<S> {
424 pub fn set_recs_fetch_config(&mut self, config: RecsFetchConfig) {
425 self.recs_fetch_config = config;
426 }
427
428 pub fn set_public_ids_batch_size(&mut self, batch_size: usize) {
429 self.public_ids_batch_size = batch_size.max(1);
430 }
431
432 pub async fn rendered_profile_text_for_user(
433 &mut self,
434 user_id: &str,
435 ) -> Result<String, HingeError> {
436 let uid = user_id.trim();
437 if uid.is_empty() {
438 return Ok(String::new());
439 }
440
441 let prompts_manager = match self.fetch_prompts_manager().await {
442 Ok(mgr) => Some(mgr),
443 Err(err) => {
444 log::warn!("Failed to prefetch prompts for rendered profile: {}", err);
445 None
446 }
447 };
448
449 let profile = self
450 .get_profiles(vec![uid.to_string()])
451 .await?
452 .into_iter()
453 .next();
454
455 let profile_content = self
456 .get_profile_content(vec![uid.to_string()])
457 .await?
458 .into_iter()
459 .next();
460
461 let text = render_profile(
462 profile.as_ref(),
463 profile_content.as_ref(),
464 prompts_manager.as_ref(),
465 );
466 Ok(text)
467 }
468 pub fn new(phone_number: impl Into<String>, storage: S, settings: Option<Settings>) -> Self {
469 let settings = settings.unwrap_or_default();
470 Self {
471 http: Http::new(),
472 settings,
473 storage,
474 secret_store: None,
475 phone_number: phone_number.into(),
476 device_id: Uuid::new_v4().to_string().to_uppercase(),
477 install_id: Uuid::new_v4().to_string().to_uppercase(),
478 session_id: Uuid::new_v4().to_string().to_uppercase(),
479 installed: false,
480 hinge_auth: None,
481 sendbird_auth: None,
482 sendbird_session_key: None,
483 sendbird_ws_cmd_tx: None,
484 sendbird_ws_broadcast_tx: None,
485 sendbird_ws_connected: false,
486 sendbird_ws_pending_requests: std::sync::Arc::new(tokio::sync::Mutex::new(
487 std::collections::HashMap::new(),
488 )),
489 recommendations: std::collections::HashMap::new(),
490 session_path: None,
491 cache_dir: None,
492 auto_persist: false,
493 recs_fetch_config: RecsFetchConfig::default(),
494 public_ids_batch_size: DEFAULT_PUBLIC_IDS_BATCH_SIZE,
495 last_recs_v2_call: None,
496 }
497 }
498
499 pub fn with_secret_store(mut self, store: std::sync::Arc<dyn SecretStore>) -> Self {
500 self.secret_store = Some(store);
501 self
502 }
503
504 async fn http_get(&self, url: &str) -> Result<reqwest::Response, HingeError> {
506 let headers = self.default_headers()?;
507 log_request("GET", url, &headers, None);
508
509 let res = self.http.get(url).headers(headers.clone()).send().await?;
510
511 log::info!("GET {} -> {}", url, res.status());
512 Ok(res)
513 }
514
515 async fn http_get_bytes(&self, url: &str) -> Result<Vec<u8>, HingeError> {
516 log::info!("GET (bytes) {}", url);
517 let res = self.http.get(url).send().await?;
518 let status = res.status();
519 if !status.is_success() {
520 let text = res
521 .text()
522 .await
523 .unwrap_or_else(|_| "Failed to read response body".into());
524 return Err(HingeError::Http(format!("status {}: {}", status, text)));
525 }
526 res.bytes()
527 .await
528 .map(|b| b.to_vec())
529 .map_err(|e| HingeError::Http(format!("Failed to download media: {}", e)))
530 }
531
532 async fn http_post(
534 &self,
535 url: &str,
536 body: &serde_json::Value,
537 ) -> Result<reqwest::Response, HingeError> {
538 let headers = self.default_headers()?;
539 log_request("POST", url, &headers, Some(body));
540
541 let res = self
542 .http
543 .post(url)
544 .headers(headers.clone())
545 .json(body)
546 .send()
547 .await?;
548
549 log::info!("POST {} -> {}", url, res.status());
550 Ok(res)
551 }
552
553 async fn http_patch(
555 &self,
556 url: &str,
557 body: &serde_json::Value,
558 ) -> Result<reqwest::Response, HingeError> {
559 let headers = self.default_headers()?;
560 log_request("PATCH", url, &headers, Some(body));
561
562 let res = self
563 .http
564 .patch(url)
565 .headers(headers.clone())
566 .json(body)
567 .send()
568 .await?;
569
570 log::info!("PATCH {} -> {}", url, res.status());
571 Ok(res)
572 }
573
574 async fn parse_response<T: serde::de::DeserializeOwned>(
576 &self,
577 res: reqwest::Response,
578 ) -> Result<T, HingeError> {
579 let status = res.status();
580 let headers = res.headers().clone();
581
582 if !status.is_success() {
583 let text = res
584 .text()
585 .await
586 .unwrap_or_else(|_| "Failed to get response text".to_string());
587 log::error!("HTTP Error {}: {}", status, text);
588 return Err(HingeError::Http(format!("status {}: {}", status, text)));
589 }
590
591 let text = res.text().await?;
592 match parse_json_with_path::<T>(&text) {
593 Ok(data) => {
594 if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&text) {
595 log_response(status, &headers, Some(&json_val));
596 }
597 Ok(data)
598 }
599 Err(e) => {
600 log::error!("Failed to parse response: {}", e);
601 log::error!("Response text: {}", text);
602 Err(e)
603 }
604 }
605 }
606
607 fn default_headers(&self) -> Result<reqwest::header::HeaderMap, HingeError> {
608 use reqwest::header::{HeaderMap, HeaderValue};
609 let mut h = HeaderMap::new();
610 h.insert("content-type", HeaderValue::from_static("application/json"));
611 h.insert("accept", HeaderValue::from_static("*/*"));
612 h.insert("accept-language", HeaderValue::from_static("en-GB"));
613 h.insert("connection", HeaderValue::from_static("keep-alive"));
614 h.insert(
615 "accept-encoding",
616 HeaderValue::from_static("gzip, deflate, br"),
617 );
618 h.insert(
619 "x-device-model-code",
620 HeaderValue::from_static("iPhone15,2"),
621 );
622 h.insert("x-device-model", HeaderValue::from_static("unknown"));
623 h.insert("x-device-region", HeaderValue::from_static("IN"));
624 h.insert(
626 "x-session-id",
627 HeaderValue::from_str(&self.session_id)
628 .map_err(|e| HingeError::Http(format!("Invalid session id header: {}", e)))?,
629 );
630 h.insert(
631 "x-device-id",
632 HeaderValue::from_str(&self.device_id)
633 .map_err(|e| HingeError::Http(format!("Invalid device id header: {}", e)))?,
634 );
635 h.insert(
636 "x-install-id",
637 HeaderValue::from_str(&self.install_id)
638 .map_err(|e| HingeError::Http(format!("Invalid install id header: {}", e)))?,
639 );
640 h.insert("x-device-platform", HeaderValue::from_static("iOS"));
641 h.insert(
642 "x-app-version",
643 HeaderValue::from_str(&self.settings.hinge_app_version)
644 .map_err(|e| HingeError::Http(format!("Invalid app version header: {}", e)))?,
645 );
646 h.insert(
647 "x-build-number",
648 HeaderValue::from_str(&self.settings.hinge_build_number)
649 .map_err(|e| HingeError::Http(format!("Invalid build number header: {}", e)))?,
650 );
651 h.insert(
652 "x-os-version",
653 HeaderValue::from_str(&self.settings.os_version)
654 .map_err(|e| HingeError::Http(format!("Invalid OS version header: {}", e)))?,
655 );
656 let ua = format!(
658 "Hinge/{} CFNetwork/3859.100.1 Darwin/25.0.0",
659 self.settings.hinge_build_number
660 );
661 h.insert(
662 "user-agent",
663 HeaderValue::from_str(&ua)
664 .map_err(|e| HingeError::Http(format!("Invalid user agent header: {}", e)))?,
665 );
666 if let Some(token) = &self.hinge_auth {
667 h.insert(
668 "authorization",
669 HeaderValue::from_str(&format!("Bearer {}", token.token))
670 .map_err(|e| HingeError::Http(format!("Invalid auth token header: {}", e)))?,
671 );
672 }
673 Ok(h)
674 }
675
676 fn sendbird_headers(&self) -> Result<reqwest::header::HeaderMap, HingeError> {
677 use reqwest::header::{HeaderMap, HeaderValue};
678 let mut h = HeaderMap::new();
679 h.insert("accept", HeaderValue::from_static("application/json"));
680 h.insert(
681 "accept-encoding",
682 HeaderValue::from_static("gzip, deflate, br"),
683 );
684 h.insert("connection", HeaderValue::from_static("Keep-Alive"));
685 h.insert(
686 "accept-language",
687 HeaderValue::from_static("en-IN,en;q=0.9"),
688 );
689 if let Some(session_key) = &self.sendbird_session_key {
690 h.insert(
691 "Session-Key",
692 HeaderValue::from_str(session_key)
693 .map_err(|e| HingeError::Http(format!("Invalid session key: {}", e)))?,
694 );
695 }
696 let ts = chrono::Utc::now().timestamp_millis();
698 h.insert(
699 "Request-Sent-Timestamp",
700 HeaderValue::from_str(&ts.to_string())
701 .map_err(|e| HingeError::Http(format!("Invalid timestamp: {}", e)))?,
702 );
703 let sendbird_hdr = format!(
705 "iOS,{},{},{}",
706 self.settings.os_version,
707 self.settings.sendbird_sdk_version,
708 self.settings.sendbird_app_id
709 );
710 h.insert(
711 "SendBird",
712 HeaderValue::from_str(&sendbird_hdr)
713 .map_err(|e| HingeError::Http(format!("Invalid SendBird header: {}", e)))?,
714 );
715 h.insert(
716 "SB-User-Agent",
717 HeaderValue::from_str(&format!("iOS/c{}///", self.settings.sendbird_sdk_version))
718 .map_err(|e| HingeError::Http(format!("Invalid SB-User-Agent: {}", e)))?,
719 );
720 h.insert(
721 "SB-SDK-User-Agent",
722 HeaderValue::from_str(&format!(
723 "main_sdk_info=chat/ios/{}&device_os_platform=ios&os_version={}",
724 self.settings.sendbird_sdk_version, self.settings.os_version
725 ))
726 .map_err(|e| HingeError::Http(format!("Invalid SB-SDK-User-Agent: {}", e)))?,
727 );
728 h.insert("user-agent", HeaderValue::from_static("Jios/4.26.0"));
729 Ok(h)
730 }
731
732 async fn sendbird_get(&self, path_and_query: &str) -> Result<reqwest::Response, HingeError> {
733 let url = format!("{}/v3{}", self.settings.sendbird_api_url, path_and_query);
734 let headers = self.sendbird_headers()?;
735 log_request("GET", &url, &headers, None);
736 let res = self.http.get(url).headers(headers.clone()).send().await?;
737 log::info!("[sendbird] GET {} -> {}", path_and_query, res.status());
738 Ok(res)
739 }
740
741 #[allow(dead_code)]
742 async fn sendbird_post_json(
743 &self,
744 path_and_query: &str,
745 body: &serde_json::Value,
746 ) -> Result<reqwest::Response, HingeError> {
747 let url = format!("{}/v3{}", self.settings.sendbird_api_url, path_and_query);
748 let mut headers = self.sendbird_headers()?;
749 use reqwest::header::HeaderValue;
750 headers.insert("content-type", HeaderValue::from_static("application/json"));
751 log_request("POST", &url, &headers, Some(body));
752 let res = self
753 .http
754 .post(url)
755 .headers(headers.clone())
756 .json(body)
757 .send()
758 .await?;
759 log::info!("[sendbird] POST {} -> {}", path_and_query, res.status());
760 Ok(res)
761 }
762
763 async fn ensure_sendbird_session(&mut self) -> Result<(), HingeError> {
764 if self.sendbird_ws_connected {
766 return Ok(());
767 }
768
769 if self.sendbird_auth.is_none() {
771 self.authenticate_with_sendbird().await?;
772 }
773
774 let (cmd_tx, broadcast_tx) = self.start_sendbird_ws().await?;
776 self.sendbird_ws_cmd_tx = Some(cmd_tx);
777 self.sendbird_ws_broadcast_tx = Some(broadcast_tx);
778 self.sendbird_ws_connected = true;
779 Ok(())
780 }
781
782 async fn start_sendbird_ws(
783 &mut self,
784 ) -> Result<
785 (
786 tokio::sync::mpsc::UnboundedSender<String>,
787 tokio::sync::broadcast::Sender<String>,
788 ),
789 HingeError,
790 > {
791 let sb = self
792 .sendbird_auth
793 .as_ref()
794 .ok_or_else(|| HingeError::Auth("sendbird token missing".into()))?;
795 let ws_url = format!(
796 "{}/?p=iOS&sv={}&pv={}&uikit_config=0&use_local_cache=0&include_extra_data=premium_feature_list,file_upload_size_limit,emoji_hash,application_attributes,notifications,message_template,ai_agent&include_poll_details=1&user_id={}&ai={}&pmce=1&expiring_session=0&config_ts=0",
797 self.settings.sendbird_ws_url,
798 self.settings.sendbird_sdk_version,
799 self.settings.os_version,
800 self.hinge_auth
801 .as_ref()
802 .map(|t| t.identity_id.clone())
803 .unwrap_or_default(),
804 self.settings.sendbird_app_id
805 );
806 let ws_ts = chrono::Utc::now().timestamp_millis().to_string();
807 let host = ws_url
808 .trim_start_matches("wss://")
809 .trim_start_matches("ws://")
810 .split('/')
811 .next()
812 .unwrap_or("");
813 let ws_key = tokio_tungstenite::tungstenite::handshake::client::generate_key();
814 let mut builder = tokio_tungstenite::tungstenite::http::Request::builder().uri(&ws_url);
815 if let Some(sk) = &self.sendbird_session_key {
816 builder = builder.header("SENDBIRD-WS-AUTH", sk);
817 } else {
818 builder = builder.header("SENDBIRD-WS-TOKEN", sb.token.clone());
819 }
820 builder = builder
821 .header("Accept", "*/*")
822 .header("Accept-Encoding", "gzip, deflate")
823 .header("Sec-WebSocket-Extensions", "permessage-deflate")
824 .header("Sec-WebSocket-Key", &ws_key)
825 .header("Sec-WebSocket-Version", "13")
826 .header("Request-Sent-Timestamp", &ws_ts)
827 .header("Host", host)
828 .header("Origin", "")
829 .header("Accept-Language", "en-IN,en;q=0.9")
830 .header("Connection", "Upgrade")
831 .header("Upgrade", "websocket")
832 .header(
833 "User-Agent",
834 &format!(
835 "Hinge/{} CFNetwork/3859.100.1 Darwin/25.0.0",
836 self.settings.hinge_build_number
837 ),
838 );
839 {
841 let mut pairs: Vec<(String, String)> = Vec::new();
842 if let Some(sk) = &self.sendbird_session_key {
843 pairs.push(("SENDBIRD-WS-AUTH".into(), sk.clone()));
844 } else {
845 pairs.push(("SENDBIRD-WS-TOKEN".into(), sb.token.clone()));
846 }
847 pairs.push(("Accept".into(), "*/*".into()));
848 pairs.push(("Accept-Encoding".into(), "gzip, deflate".into()));
849 pairs.push((
850 "Sec-WebSocket-Extensions".into(),
851 "permessage-deflate".into(),
852 ));
853 pairs.push(("Accept-Language".into(), "en-IN,en;q=0.9".into()));
854 pairs.push(("Host".into(), host.to_string()));
855 pairs.push(("Origin".into(), "".into()));
856 pairs.push(("Sec-WebSocket-Key".into(), ws_key.clone()));
857 pairs.push(("Sec-WebSocket-Version".into(), "13".into()));
858 pairs.push(("Request-Sent-Timestamp".into(), ws_ts.clone()));
859 pairs.push(("Connection".into(), "Upgrade".into()));
860 pairs.push(("Upgrade".into(), "websocket".into()));
861 pairs.push((
862 "User-Agent".into(),
863 format!(
864 "Hinge/{} CFNetwork/3859.100.1 Darwin/25.0.0",
865 self.settings.hinge_build_number
866 ),
867 ));
868 log::info!("━━━━━━━━━━ WS REQUEST ━━━━━━━━━━");
869 log::info!("GET {}", ws_url);
870 log::debug!("Headers:\n{}", crate::logging::format_ws_headers(&pairs));
871 log::info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
872 }
873
874 let req: tokio_tungstenite::tungstenite::http::Request<()> = builder
875 .body(())
876 .map_err(|e| HingeError::Http(e.to_string()))?;
877 let (ws, _resp) = tokio_tungstenite::connect_async(req)
878 .await
879 .map_err(|e| HingeError::Http(e.to_string()))?;
880 let (write_half, mut read_half) = ws.split();
881 let write_half = std::sync::Arc::new(tokio::sync::Mutex::new(write_half));
882
883 let (tx_cmd, mut rx_cmd) = tokio::sync::mpsc::unbounded_channel::<String>();
884 let (tx_broadcast, _rx_broadcast) = tokio::sync::broadcast::channel::<String>(1024);
885 let (sk_tx, sk_rx) = tokio::sync::oneshot::channel::<String>();
886
887 {
889 let write_for_pong = write_half.clone();
890 let tx_broadcast_c = tx_broadcast.clone();
891 let pending_requests = self.sendbird_ws_pending_requests.clone();
892 tokio::spawn(async move {
893 let mut sk_tx_opt = Some(sk_tx);
894 while let Some(msg) = read_half.next().await {
895 match msg {
896 Ok(Message::Ping(_)) => {
897 let mut w = write_for_pong.lock().await;
898 let _ = w.send(Message::Pong(Vec::new().into())).await;
899 }
900 Ok(Message::Text(t)) => {
901 let t = t.to_string();
902 if t.starts_with("LOGI")
904 && let Some(start) = t.find('{')
905 && let Ok(val) =
906 serde_json::from_str::<serde_json::Value>(&t[start..])
907 {
908 if let Some(k) = val.get("key").and_then(|v| v.as_str()) {
909 let _ = tx_broadcast_c.send(format!("__SESSION_KEY__:{}", k));
910 if let Some(tx) = sk_tx_opt.take() {
911 let _ = tx.send(k.to_string());
912 }
913 }
914 log::info!(
916 "[sendbird ws] LOGI received - user_id: {}, ping_interval: {}, pong_timeout: {}",
917 val.get("user_id")
918 .and_then(|v| v.as_str())
919 .unwrap_or("unknown"),
920 val.get("ping_interval")
921 .and_then(|v| v.as_i64())
922 .unwrap_or(0),
923 val.get("pong_timeout")
924 .and_then(|v| v.as_i64())
925 .unwrap_or(0)
926 );
927 }
928 else if t.starts_with("PING") {
930 log::debug!("[sendbird ws] Received PING, sending PONG");
931 if let Some(start) = t.find('{')
932 && let Ok(_val) =
933 serde_json::from_str::<serde_json::Value>(&t[start..])
934 {
935 let pong_response = json!({
936 "sts": chrono::Utc::now().timestamp_millis(),
937 "ts": chrono::Utc::now().timestamp_millis()
938 });
939 let pong_msg = format!("PONG{}", pong_response);
940 let mut w = write_for_pong.lock().await;
941 let _ = w.send(Message::Text(pong_msg.into())).await;
942 }
943 }
944 else if t.starts_with("READ") && t.contains("channel_id") {
946 log::debug!("[sendbird ws] Received READ acknowledgment");
947 if let Some(start) = t.find('{')
949 && let Ok(val) =
950 serde_json::from_str::<serde_json::Value>(&t[start..])
951 && let Some(req_id) = val.get("req_id").and_then(|v| v.as_str())
952 {
953 let mut pending = pending_requests.lock().await;
954 if let Some(tx) = pending.remove(req_id) {
955 let _ = tx.send(val.clone());
956 log::debug!(
957 "[sendbird ws] Matched READ response for req_id: {}",
958 req_id
959 );
960 }
961 }
962 }
963 if t.starts_with("SYEV")
966 && let Some(start) = t.find('{')
967 && let Ok(val) =
968 serde_json::from_str::<serde_json::Value>(&t[start..])
969 {
970 let _ = tx_broadcast_c.send(t.clone());
972 if let Ok(evt) = serde_json::from_value::<
974 crate::models::SendbirdSyevEvent,
975 >(val.clone())
976 {
977 if evt.cat
979 == crate::models::SendbirdSyevEvent::CATEGORY_TYPING_START
980 {
981 log::debug!(
982 "[sendbird ws] SYEV typing start user={} channel={}",
983 evt.data
984 .as_ref()
985 .map(|u| u.user_id.as_str())
986 .unwrap_or("unknown"),
987 evt.channel_url
988 );
989 } else if evt.cat
990 == crate::models::SendbirdSyevEvent::CATEGORY_TYPING_END
991 {
992 log::debug!(
993 "[sendbird ws] SYEV typing end user={} channel={}",
994 evt.data
995 .as_ref()
996 .map(|u| u.user_id.as_str())
997 .unwrap_or("unknown"),
998 evt.channel_url
999 );
1000 }
1001 if let Ok(json_evt) = serde_json::to_string(&evt) {
1003 let _ =
1004 tx_broadcast_c.send(format!("__SYEV__:{}", json_evt));
1005 }
1006 continue;
1007 }
1008 }
1009 let _ = tx_broadcast_c.send(t);
1010 }
1011 Ok(Message::Binary(b)) => {
1012 let _ = tx_broadcast_c.send(String::from_utf8_lossy(&b).into_owned());
1013 }
1014 Ok(Message::Pong(_)) => {}
1015 Ok(Message::Close(frame)) => {
1016 if let Some(cf) = frame {
1017 let code_u16: u16 = cf.code.into();
1018
1019 let now = chrono::Utc::now();
1023 let ms_timestamp = now.timestamp_millis();
1024
1025 let last_5_of_ms = (ms_timestamp % 100000) as u16;
1027 let last_5_of_seconds = ((ms_timestamp / 1000) % 100000) as u16;
1028 let seconds_today = (now.timestamp() % 86400) as u16;
1029 let ms_today = ((now.timestamp() % 86400) * 1000
1030 + now.timestamp_subsec_millis() as i64)
1031 as u32;
1032 let ms_today_mod = (ms_today % 65536) as u16; log::debug!(
1035 "[sendbird ws] Time analysis - code: {}, last5_ms: {}, last5_sec: {}, sec_today: {}, ms_today_mod: {}",
1036 code_u16,
1037 last_5_of_ms,
1038 last_5_of_seconds,
1039 seconds_today,
1040 ms_today_mod
1041 );
1042
1043 let code_desc = match code_u16 {
1044 1000 => "Normal closure",
1046 1001 => "Going away",
1047 1002 => "Protocol error",
1048 1003 => "Unsupported data",
1049 1006 => "Abnormal closure",
1050 1008 => "Policy violation",
1051 1009 => "Message too big",
1052 1010 => "Mandatory extension",
1053 1011 => "Internal server error",
1054 1015 => "TLS handshake failure",
1055 _ if code_u16 >= 10000 => {
1057 "Sendbird dynamic code (possibly time-derived)"
1058 }
1059 _ => "Non-standard close code",
1060 };
1061 log::warn!(
1062 "[sendbird ws] Connection closed - code: {} ({}), reason: {}",
1063 code_u16,
1064 code_desc,
1065 cf.reason
1066 );
1067 let _ = tx_broadcast_c
1068 .send(format!("__CLOSE__:{}:{}", code_u16, cf.reason));
1069
1070 if !cf.reason.is_empty() {
1073 log::info!(
1074 "[sendbird ws] Close reason provided: {}",
1075 cf.reason
1076 );
1077 }
1078 } else {
1079 log::warn!("[sendbird ws] Connection closed without frame");
1080 let _ = tx_broadcast_c.send("__CLOSE__".into());
1081 }
1082 break;
1083 }
1084 Ok(_) => {}
1085 Err(e) => {
1086 log::error!("[sendbird ws] WebSocket error: {}", e);
1087 let _ = tx_broadcast_c.send(format!("__ERROR__:{}", e));
1088 break;
1089 }
1090 }
1091 }
1092 });
1093 }
1094
1095 {
1097 let write_for_cmds = write_half.clone();
1098 tokio::spawn(async move {
1099 while let Some(cmd) = rx_cmd.recv().await {
1100 let mut w = write_for_cmds.lock().await;
1101
1102 if cmd.starts_with("__CLOSE__:") {
1104 let parts: Vec<&str> = cmd
1106 .strip_prefix("__CLOSE__:")
1107 .unwrap_or("")
1108 .split(':')
1109 .collect();
1110 let code = parts
1111 .first()
1112 .and_then(|s| s.parse::<u16>().ok())
1113 .unwrap_or(1000);
1114 let reason = parts.get(1).unwrap_or(&"").to_string();
1115
1116 let close_frame = tokio_tungstenite::tungstenite::protocol::CloseFrame {
1118 code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::from(code),
1119 reason: reason.into(),
1120 };
1121 let _ = w.send(Message::Close(Some(close_frame))).await;
1122 break; } else {
1124 let _ = w.send(Message::Text(cmd.into())).await;
1126 }
1127 }
1128 });
1129 }
1130
1131 if let Ok(k) = sk_rx.await {
1133 self.sendbird_session_key = Some(k);
1134 log::info!("[sendbird] Session-Key captured");
1135 if let Some(path) = &self.session_path {
1136 let _ = self.save_session(path);
1137 }
1138 } else {
1139 log::warn!("Sendbird LOGI not received before startup return");
1140 }
1141 Ok((tx_cmd, tx_broadcast))
1142 }
1143
1144 pub async fn sendbird_list_my_group_channels(
1145 &mut self,
1146 user_id: &str,
1147 limit: usize,
1148 ) -> Result<serde_json::Value, HingeError> {
1149 self.ensure_sendbird_session().await?;
1150 let q = format!(
1151 "/users/{}/my_group_channels?&include_left_channel=false&member_state_filter=all&super_mode=all&show_latest_message=false&show_pinned_messages=false&unread_filter=all&show_delivery_receipt=true&show_conversation=false&show_member=true&show_empty=true&limit={}&user_id={}&is_feed_channel=false&order=latest_last_message&hidden_mode=unhidden_only&distinct_mode=all&show_read_receipt=true&show_metadata=true&is_explicit_request=true&show_frozen=true&public_mode=all&include_chat_notification=false",
1152 user_id, limit, user_id
1153 );
1154 let res = self.sendbird_get(&q).await?;
1155 self.parse_response(res).await
1156 }
1157
1158 pub async fn sendbird_list_channels_typed(
1159 &mut self,
1160 limit: usize,
1161 ) -> Result<crate::models::SendbirdChannelsResponse, HingeError> {
1162 let user_id = self
1163 .hinge_auth
1164 .as_ref()
1165 .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
1166 .identity_id
1167 .clone();
1168 let limit = limit.clamp(1, 200);
1169 let raw = self
1170 .sendbird_list_my_group_channels(&user_id, limit)
1171 .await?;
1172 parse_json_value_with_path(raw).map_err(|e| {
1173 HingeError::Serde(format!("Failed to parse Sendbird channels response: {}", e))
1174 })
1175 }
1176
1177 pub async fn sendbird_get_channel(
1178 &mut self,
1179 channel_url: &str,
1180 ) -> Result<serde_json::Value, HingeError> {
1181 self.ensure_sendbird_session().await?;
1182 let q = format!(
1183 "/sdk/group_channels/{}?&is_feed_channel=false&show_latest_message=false&show_metadata=false&show_empty=false&show_member=true&show_frozen=false&show_read_receipt=true&show_pinned_messages=false&include_chat_notification=false&show_delivery_receipt=true&show_conversation=true",
1184 channel_url
1185 );
1186 let res = self.sendbird_get(&q).await?;
1187 self.parse_response(res).await
1188 }
1189
1190 pub async fn sendbird_get_channel_typed(
1191 &mut self,
1192 channel_url: &str,
1193 ) -> Result<SendbirdGroupChannel, HingeError> {
1194 let value = self.sendbird_get_channel(channel_url).await?;
1195 parse_json_value_with_path(value)
1196 .map_err(|e| HingeError::Serde(format!("Failed to parse channel: {}", e)))
1197 }
1198
1199 pub async fn sendbird_get_messages(
1200 &mut self,
1201 channel_url: &str,
1202 message_ts: i64,
1203 prev_limit: usize,
1204 ) -> Result<crate::models::SendbirdMessagesResponse, HingeError> {
1205 self.ensure_sendbird_session().await?;
1206 let q = format!(
1207 "/group_channels/{}/messages?&include_reply_type=all&sdk_source=external_legacy&with_sorted_meta_array=true&message_ts={}&is_sdk=true&include_reactions_summary=true&include_parent_message_info=false&reverse=true&prev_limit={}&custom_types=%2A&include=false&next_limit=0&include_poll_details=true&show_subchannel_messages_only=false&include_thread_info=false",
1208 channel_url, message_ts, prev_limit
1209 );
1210 let res = self.sendbird_get(&q).await?;
1211 self.parse_response(res).await
1212 }
1213
1214 pub async fn sendbird_get_full_messages(
1215 &mut self,
1216 channel_url: &str,
1217 ) -> Result<Vec<SendbirdMessage>, HingeError> {
1218 self.ensure_sendbird_session().await?;
1219 const PAGE_SIZE: usize = 120;
1220 let mut anchor = chrono::Utc::now().timestamp_millis();
1221 let mut seen: HashSet<String> = HashSet::new();
1222 let mut collected: Vec<(i64, SendbirdMessage)> = Vec::new();
1223
1224 loop {
1225 let batch = self
1226 .sendbird_get_messages(channel_url, anchor, PAGE_SIZE)
1227 .await?;
1228 if batch.messages.is_empty() {
1229 break;
1230 }
1231 let mut earliest = anchor;
1232 let mut added = 0usize;
1233 for message in batch.messages {
1234 if seen.insert(message.message_id.clone()) {
1235 let ts = parse_ts(&message.created_at).unwrap_or(anchor);
1236 earliest = min(earliest, ts.saturating_sub(1));
1237 collected.push((ts, message));
1238 added += 1;
1239 }
1240 }
1241 if added == 0 {
1242 break;
1243 }
1244 if earliest >= anchor || earliest <= 0 {
1245 break;
1246 }
1247 anchor = earliest;
1248 if collected.len() >= 4000 {
1249 log::warn!(
1250 "[sendbird] Stopping history fetch after {} messages to avoid huge exports",
1251 collected.len()
1252 );
1253 break;
1254 }
1255 }
1256
1257 collected.sort_by_key(|(ts, _)| *ts);
1258 Ok(collected.into_iter().map(|(_, msg)| msg).collect())
1259 }
1260
1261 pub async fn export_chat(
1262 &mut self,
1263 input: ExportChatInput,
1264 ) -> Result<ExportChatResult, HingeError> {
1265 self.ensure_sendbird_session().await?;
1266 let auth = self
1267 .hinge_auth
1268 .as_ref()
1269 .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
1270 .clone();
1271 let self_user_id = auth.identity_id.clone();
1272
1273 let prompts_manager = match self.fetch_prompts_manager().await {
1274 Ok(mgr) => Some(mgr),
1275 Err(err) => {
1276 log::warn!("Failed to prefetch prompts for export: {}", err);
1277 None
1278 }
1279 };
1280
1281 let channel = self.sendbird_get_channel_typed(&input.channel_url).await?;
1282 let partner = channel
1283 .members
1284 .iter()
1285 .find(|member| !member.user_id.is_empty() && member.user_id != self_user_id)
1286 .cloned()
1287 .ok_or_else(|| HingeError::Http("unable to determine conversation partner".into()))?;
1288
1289 let peer_id = partner.user_id.clone();
1290 let profile = self
1291 .get_profiles(vec![peer_id.clone()])
1292 .await?
1293 .into_iter()
1294 .next();
1295 let profile_content = self
1296 .get_profile_content(vec![peer_id.clone()])
1297 .await?
1298 .into_iter()
1299 .next();
1300
1301 let display_name = profile
1302 .as_ref()
1303 .map(|p| p.profile.first_name.clone())
1304 .filter(|name| !name.trim().is_empty())
1305 .or_else(|| {
1306 if !partner.nickname.trim().is_empty() {
1307 Some(partner.nickname.clone())
1308 } else {
1309 None
1310 }
1311 })
1312 .unwrap_or_else(|| peer_id.clone());
1313
1314 let age_label = profile
1315 .as_ref()
1316 .and_then(|p| p.profile.age)
1317 .map(|age| age.to_string())
1318 .unwrap_or_else(|| "Unknown age".to_string());
1319
1320 let initiation_summary_lines = if let Some(lines) = input.initiation_summary_lines.clone() {
1321 if lines.is_empty() { None } else { Some(lines) }
1322 } else {
1323 match self.get_connections_v2().await {
1324 Ok(resp) => resp
1325 .connections
1326 .into_iter()
1327 .find(|conn| {
1328 let initiator = conn.initiator_id.trim();
1329 let subject = conn.subject_id.trim();
1330 (!initiator.is_empty() && initiator == self_user_id && subject == peer_id)
1331 || (!subject.is_empty()
1332 && subject == self_user_id
1333 && initiator == peer_id)
1334 })
1335 .and_then(|conn| {
1336 summarize_connection_initiation(
1337 &conn,
1338 &self_user_id,
1339 &peer_id,
1340 &display_name,
1341 )
1342 }),
1343 Err(err) => {
1344 log::warn!(
1345 "Failed to fetch connections for initiation summary: {}",
1346 err
1347 );
1348 None
1349 }
1350 }
1351 };
1352
1353 let base_dir = Path::new(&input.output_dir);
1354 let export_dir = base_dir.to_path_buf();
1355 fs::create_dir_all(&export_dir).map_err(|e| HingeError::Storage(e.to_string()))?;
1356
1357 let messages = self.sendbird_get_full_messages(&input.channel_url).await?;
1358
1359 let mut transcript = String::new();
1360 writeln!(transcript, "Chat with {} ({})", display_name, age_label).ok();
1361 writeln!(transcript, "Channel: {}", input.channel_url).ok();
1362 writeln!(transcript, "Exported at {}", Utc::now().to_rfc3339()).ok();
1363 if let Some(lines) = &initiation_summary_lines {
1364 for line in lines {
1365 writeln!(transcript, "{line}").ok();
1366 }
1367 }
1368 transcript.push('\n');
1369
1370 let mut media_files: Vec<ExportedMediaFile> = Vec::new();
1371
1372 if input.include_media
1373 && let Some(ref content) = profile_content
1374 {
1375 for (idx, photo) in content.content.photos.iter().enumerate() {
1376 let mut file_name = format!("profile_photo_{}", idx + 1);
1377 if let Some(ext) = photo
1378 .url
1379 .split('.')
1380 .next_back()
1381 .filter(|part| part.len() <= 5)
1382 {
1383 file_name.push('.');
1384 file_name.push_str(ext);
1385 }
1386 let sanitized = sanitize_component(&file_name);
1387 let target_path = export_dir.join(&sanitized);
1388 let bytes = self.http_get_bytes(&photo.url).await?;
1389 fs::write(&target_path, &bytes).map_err(|e| HingeError::Storage(e.to_string()))?;
1390 media_files.push(ExportedMediaFile {
1391 message_id: format!("profile_photo_{}", idx + 1),
1392 file_name: sanitized.clone(),
1393 file_path: target_path.to_string_lossy().to_string(),
1394 });
1395 }
1396 }
1397
1398 for message in &messages {
1399 let timestamp = parse_ts(&message.created_at).unwrap_or(0);
1400 let local_time: DateTime<Local> = DateTime::<Utc>::from_timestamp_millis(timestamp)
1401 .map(|dt| dt.with_timezone(&Local))
1402 .unwrap_or_else(Local::now);
1403 let sender = if message.user.user_id == self_user_id {
1404 "You".to_string()
1405 } else if !message.user.nickname.is_empty() {
1406 message.user.nickname.clone()
1407 } else {
1408 display_name.clone()
1409 };
1410 let body = if !message.message.trim().is_empty() {
1411 message.message.clone()
1412 } else if !message.data.trim().is_empty() {
1413 message.data.clone()
1414 } else if !message.custom_type.trim().is_empty() {
1415 format!("[{} message]", message.custom_type)
1416 } else {
1417 "[non-text message]".into()
1418 };
1419
1420 writeln!(
1421 transcript,
1422 "{} - {}: {}",
1423 local_time.format("%Y-%m-%d %H:%M:%S"),
1424 sender,
1425 body
1426 )
1427 .ok();
1428
1429 if input.include_media
1430 && let Some((url, name)) = attachment_from_value(&message.file)
1431 {
1432 let sanitized = sanitize_component(&name);
1433 let target_path = export_dir.join(&sanitized);
1434 let bytes = self.http_get_bytes(&url).await?;
1435 fs::write(&target_path, &bytes).map_err(|e| HingeError::Storage(e.to_string()))?;
1436 writeln!(transcript, " [Saved attachment: {}]", sanitized).ok();
1437 media_files.push(ExportedMediaFile {
1438 message_id: message.message_id.clone(),
1439 file_name: sanitized.clone(),
1440 file_path: target_path.to_string_lossy().to_string(),
1441 });
1442 }
1443 }
1444
1445 let transcript_path = export_dir.join("chat.txt");
1446 fs::write(&transcript_path, transcript).map_err(|e| HingeError::Storage(e.to_string()))?;
1447
1448 let profile_text = render_profile(
1449 profile.as_ref(),
1450 profile_content.as_ref(),
1451 prompts_manager.as_ref(),
1452 );
1453 let profile_path = if !profile_text.trim().is_empty() {
1454 let path = export_dir.join("profile.txt");
1455 fs::write(&path, profile_text).map_err(|e| HingeError::Storage(e.to_string()))?;
1456 Some(path)
1457 } else {
1458 None
1459 };
1460
1461 Ok(ExportChatResult {
1462 folder_path: export_dir.to_string_lossy().to_string(),
1463 transcript_path: transcript_path.to_string_lossy().to_string(),
1464 profile_path: profile_path.map(|p| p.to_string_lossy().to_string()),
1465 message_count: messages.len().min(i32::MAX as usize) as i32,
1466 media_files,
1467 })
1468 }
1469
1470 pub async fn sendbird_create_distinct_dm(
1471 &mut self,
1472 self_user_id: &str,
1473 peer_user_id: &str,
1474 data_mm: i32,
1475 ) -> Result<serde_json::Value, HingeError> {
1476 self.ensure_sendbird_session().await?;
1477 let payload = json!({
1478 "is_ephemeral": false,
1479 "is_exclusive": false,
1480 "data": format!("{{\n \"mm\" : {}\n}}", data_mm),
1481 "user_ids": [peer_user_id, self_user_id],
1482 "is_super": false,
1483 "is_distinct": true,
1484 "strict": false,
1485 "is_broadcast": false,
1486 "message_survival_seconds": -1,
1487 "is_public": false
1488 });
1489 let url = format!(
1490 "{}/v3{}",
1491 self.settings.sendbird_api_url, "/group_channels?"
1492 );
1493 let mut headers = self.sendbird_headers()?;
1494 use reqwest::header::HeaderValue;
1495 headers.insert(
1496 "content-type",
1497 HeaderValue::from_static("application/x-www-form-urlencoded"),
1498 );
1499 log_request("POST", &url, &headers, Some(&payload));
1500 let res = self
1501 .http
1502 .post(url)
1503 .headers(headers)
1504 .body(serde_json::to_string(&payload).unwrap_or_default())
1505 .send()
1506 .await?;
1507 log::info!("[sendbird] POST /group_channels -> {}", res.status());
1508 self.parse_response(res).await
1509 }
1510
1511 pub async fn sendbird_get_or_create_dm_channel(
1512 &mut self,
1513 self_user_id: &str,
1514 peer_user_id: &str,
1515 ) -> Result<String, HingeError> {
1516 let q = format!(
1518 "/users/{}/my_group_channels?&members_exactly_in={}&show_latest_message=false&distinct_mode=all&hidden_mode=unhidden_only&show_pinned_messages=false&show_metadata=true&member_state_filter=all&user_id={}&is_explicit_request=true&public_mode=all&include_left_channel=false&show_conversation=false&show_frozen=true&is_feed_channel=false&show_delivery_receipt=true&unread_filter=all&super_mode=all&show_member=true&show_read_receipt=true&order=chronological&show_empty=true&include_chat_notification=false&limit=1",
1519 self_user_id, peer_user_id, self_user_id
1520 );
1521 self.ensure_sendbird_session().await?;
1522 let res = self.sendbird_get(&q).await?;
1523 let v: serde_json::Value = self.parse_response(res).await?;
1524 if let Some(url) = v
1525 .get("channels")
1526 .and_then(|c| c.as_array())
1527 .and_then(|arr| arr.first())
1528 .and_then(|c| c.get("channel_url"))
1529 .and_then(|s| s.as_str())
1530 {
1531 return Ok(url.to_string());
1532 }
1533 let created = self
1534 .sendbird_create_distinct_dm(self_user_id, peer_user_id, 1)
1535 .await?;
1536 let url = created
1537 .get("channel_url")
1538 .and_then(|s| s.as_str())
1539 .ok_or_else(|| HingeError::Http("missing channel_url in create response".into()))?;
1540 Ok(url.to_string())
1541 }
1542
1543 pub async fn ensure_sendbird_channel_with(
1544 &mut self,
1545 peer_user_id: &str,
1546 ) -> Result<SendbirdChannelHandle, HingeError> {
1547 let self_user_id = self
1548 .hinge_auth
1549 .as_ref()
1550 .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
1551 .identity_id
1552 .clone();
1553 let channel_url = self
1554 .sendbird_get_or_create_dm_channel(&self_user_id, peer_user_id)
1555 .await?;
1556 Ok(SendbirdChannelHandle { channel_url })
1557 }
1558
1559 pub async fn sendbird_init_flow(&mut self) -> Result<serde_json::Value, HingeError> {
1560 self.ensure_sendbird_session().await?;
1562 let user_id = self
1563 .hinge_auth
1564 .as_ref()
1565 .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
1566 .identity_id
1567 .clone();
1568 let res = self.sendbird_list_my_group_channels(&user_id, 20).await?;
1569 Ok(res)
1570 }
1571
1572 pub async fn sendbird_creds(&mut self) -> Result<serde_json::Value, HingeError> {
1574 if self.sendbird_auth.is_none() {
1576 self.authenticate_with_sendbird().await?;
1577 }
1578 let app_id = self.settings.sendbird_app_id.clone();
1579 let token = self
1580 .sendbird_auth
1581 .as_ref()
1582 .map(|t| t.token.clone())
1583 .unwrap_or_default();
1584 Ok(serde_json::json!({
1585 "appId": app_id,
1586 "token": token
1587 }))
1588 }
1589
1590 pub async fn sendbird_ws_subscribe(
1592 &mut self,
1593 ) -> Result<
1594 (
1595 tokio::sync::mpsc::UnboundedSender<String>,
1596 tokio::sync::broadcast::Receiver<String>,
1597 ),
1598 HingeError,
1599 > {
1600 self.ensure_sendbird_session().await?;
1601 let cmd = self
1602 .sendbird_ws_cmd_tx
1603 .as_ref()
1604 .cloned()
1605 .ok_or_else(|| HingeError::Http("sendbird ws not started".into()))?;
1606 let tx = self
1607 .sendbird_ws_broadcast_tx
1608 .as_ref()
1609 .cloned()
1610 .ok_or_else(|| HingeError::Http("sendbird ws broadcast not available".into()))?;
1611 let rx = tx.subscribe();
1612 Ok((cmd, rx))
1613 }
1614
1615 pub async fn sendbird_ws_send_command(&mut self, command: String) -> Result<(), HingeError> {
1617 self.ensure_sendbird_session().await?;
1618 let tx = self
1619 .sendbird_ws_cmd_tx
1620 .as_ref()
1621 .cloned()
1622 .ok_or_else(|| HingeError::Http("sendbird ws not started".into()))?;
1623 tx.send(command)
1624 .map_err(|e| HingeError::Http(format!("Failed to send WS command: {}", e)))?;
1625 Ok(())
1626 }
1627
1628 pub async fn sendbird_ws_send_read(&mut self, channel_url: &str) -> Result<(), HingeError> {
1630 let req_id = Uuid::new_v4().to_string().to_uppercase();
1631 let read_command = format!(
1632 r#"READ{{"req_id":"{}","channel_url":"{}"}}"#,
1633 req_id, channel_url
1634 );
1635 self.sendbird_ws_send_command(read_command).await
1636 }
1637
1638 pub async fn sendbird_ws_send_read_and_wait(
1640 &mut self,
1641 channel_url: &str,
1642 ) -> Result<crate::models::SendbirdReadResponse, HingeError> {
1643 self.ensure_sendbird_session().await?;
1644
1645 let req_id = Uuid::new_v4().to_string().to_uppercase();
1647
1648 let (tx, rx) = tokio::sync::oneshot::channel();
1650
1651 {
1653 let mut pending = self.sendbird_ws_pending_requests.lock().await;
1654 pending.insert(req_id.clone(), tx);
1655 }
1656
1657 let read_command = format!(
1659 r#"READ{{"req_id":"{}","channel_url":"{}"}}"#,
1660 req_id, channel_url
1661 );
1662 self.sendbird_ws_send_command(read_command).await?;
1663
1664 match tokio::time::timeout(Duration::from_secs(5), rx).await {
1666 Ok(Ok(response)) => {
1667 parse_json_value_with_path(response)
1669 .map_err(|e| HingeError::Http(format!("Failed to parse READ response: {}", e)))
1670 }
1671 Ok(Err(_)) => {
1672 let mut pending = self.sendbird_ws_pending_requests.lock().await;
1674 pending.remove(&req_id);
1675 Err(HingeError::Http("READ response channel dropped".into()))
1676 }
1677 Err(_) => {
1678 let mut pending = self.sendbird_ws_pending_requests.lock().await;
1680 pending.remove(&req_id);
1681 Err(HingeError::Http("READ response timeout".into()))
1682 }
1683 }
1684 }
1685
1686 pub async fn sendbird_ws_send_ping(&mut self) -> Result<(), HingeError> {
1688 let req_id = Uuid::new_v4().to_string().to_uppercase();
1689 let ping_command = format!(r#"PING{{"req_id":"{}"}}"#, req_id);
1690 self.sendbird_ws_send_command(ping_command).await
1691 }
1692
1693 pub async fn sendbird_ws_send_typing_start(
1695 &mut self,
1696 channel_url: &str,
1697 ) -> Result<(), HingeError> {
1698 let timestamp = chrono::Utc::now().timestamp_millis();
1699 let tpst_command = format!(
1700 r#"TPST{{"req_id":null,"channel_url":"{}","time":{}}}"#,
1701 channel_url, timestamp
1702 );
1703 self.sendbird_ws_send_command(tpst_command).await
1704 }
1705
1706 pub async fn sendbird_ws_send_typing_end(
1708 &mut self,
1709 channel_url: &str,
1710 ) -> Result<(), HingeError> {
1711 let timestamp = chrono::Utc::now().timestamp_millis();
1712 let tpen_command = format!(
1713 r#"TPEN{{"req_id":null,"channel_url":"{}","time":{}}}"#,
1714 channel_url, timestamp
1715 );
1716 self.sendbird_ws_send_command(tpen_command).await
1717 }
1718
1719 pub async fn sendbird_ws_send_enter_channel(
1721 &mut self,
1722 channel_url: &str,
1723 ) -> Result<(), HingeError> {
1724 let entr_command = format!(r#"ENTR{{"req_id":null,"channel_url":"{}"}}"#, channel_url);
1725 self.sendbird_ws_send_command(entr_command).await
1726 }
1727
1728 pub async fn sendbird_ws_send_exit_channel(
1730 &mut self,
1731 channel_url: &str,
1732 ) -> Result<(), HingeError> {
1733 let exit_command = format!(r#"EXIT{{"req_id":null,"channel_url":"{}"}}"#, channel_url);
1734 self.sendbird_ws_send_command(exit_command).await
1735 }
1736
1737 pub async fn sendbird_ws_send_message_ack(
1739 &mut self,
1740 channel_url: &str,
1741 message_id: &str,
1742 ) -> Result<(), HingeError> {
1743 let mack_command = format!(
1744 r#"MACK{{"req_id":null,"channel_url":"{}","msg_id":"{}"}}"#,
1745 channel_url, message_id
1746 );
1747 self.sendbird_ws_send_command(mack_command).await
1748 }
1749
1750 pub async fn sendbird_ws_close(
1752 &mut self,
1753 code: Option<u16>,
1754 reason: Option<String>,
1755 ) -> Result<(), HingeError> {
1756 if let Some(ref tx) = self.sendbird_ws_cmd_tx {
1758 let close_code = code.unwrap_or(1000); let close_reason = reason.unwrap_or_else(|| "Client initiated close".to_string());
1761
1762 let close_command = format!("__CLOSE__:{}:{}", close_code, close_reason);
1765 let _ = tx.send(close_command);
1766
1767 log::info!(
1768 "[sendbird ws] Closing connection with code {} reason: {}",
1769 close_code,
1770 close_reason
1771 );
1772 }
1773
1774 self.sendbird_ws_cmd_tx = None;
1776 self.sendbird_ws_broadcast_tx = None;
1777 self.sendbird_ws_connected = false;
1778
1779 let mut pending = self.sendbird_ws_pending_requests.lock().await;
1781 pending.clear();
1782
1783 Ok(())
1784 }
1785
1786 pub async fn sendbird_ws_ensure_connected(&mut self) -> Result<bool, HingeError> {
1788 if self.sendbird_ws_cmd_tx.is_some() {
1790 if self.sendbird_ws_send_ping().await.is_ok() {
1792 return Ok(true);
1793 }
1794 }
1795
1796 self.sendbird_ws_cmd_tx = None;
1798 self.sendbird_ws_broadcast_tx = None;
1799
1800 log::info!("[sendbird ws] Reconnecting WebSocket...");
1802 self.start_sendbird_ws().await?;
1803 Ok(true)
1804 }
1805
1806 async fn ensure_device_registered(&mut self) -> Result<(), HingeError> {
1807 if self.installed {
1808 return Ok(());
1809 }
1810 let url = format!("{}/identity/install", self.settings.base_url);
1811 let body = json!({"installId": self.install_id});
1812 let res = self
1813 .http_post(&url, &body)
1814 .await
1815 .map_err(|e| HingeError::Http(format!("Failed to register device: {}", e)))?;
1816
1817 if !res.status().is_success() {
1818 return Err(HingeError::Http(format!(
1819 "Device registration failed with status {}",
1820 res.status()
1821 )));
1822 }
1823 self.installed = true;
1824 Ok(())
1825 }
1826
1827 pub async fn initiate_login(&mut self) -> Result<(), HingeError> {
1828 self.ensure_device_registered().await?;
1829 let url = format!("{}/auth/sms/v2/initiate", self.settings.base_url);
1830 let body = json!({"deviceId": self.device_id, "phoneNumber": self.phone_number});
1831 let res = self
1832 .http_post(&url, &body)
1833 .await
1834 .map_err(|e| HingeError::Http(format!("Failed to initiate SMS login: {}", e)))?;
1835 if !res.status().is_success() {
1836 return Err(HingeError::Http(format!(
1837 "SMS initiation failed with status {}",
1838 res.status()
1839 )));
1840 }
1841 Ok(())
1842 }
1843
1844 pub async fn submit_otp(&mut self, otp: &str) -> Result<LoginTokens, HingeError> {
1845 let url = format!("{}/auth/sms/v2", self.settings.base_url);
1846 let body = json!({
1847 "installId": self.install_id,
1848 "deviceId": self.device_id,
1849 "phoneNumber": self.phone_number,
1850 "otp": otp,
1851 });
1852 let res = self.http_post(&url, &body).await?;
1853 if res.status() == reqwest::StatusCode::PRECONDITION_FAILED {
1854 let v: serde_json::Value = res.json().await?;
1855 let case_id = v
1856 .get("caseId")
1857 .and_then(|v| v.as_str())
1858 .unwrap_or("")
1859 .to_string();
1860 let email = v
1861 .get("email")
1862 .and_then(|v| v.as_str())
1863 .unwrap_or("")
1864 .to_string();
1865 return Err(HingeError::Email2FA { case_id, email });
1866 }
1867 if !res.status().is_success() {
1868 return Err(HingeError::Http(format!("status {}", res.status())));
1869 }
1870 let v = self.parse_response::<LoginTokens>(res).await?;
1871 if let Some(t) = v.hinge_auth_token.clone() {
1872 self.hinge_auth = Some(t);
1873 }
1874 if let Some(t) = v.sendbird_auth_token.clone() {
1875 self.sendbird_auth = Some(t);
1876 }
1877 Ok(v)
1878 }
1879
1880 pub async fn submit_email_code(
1881 &mut self,
1882 case_id: &str,
1883 email_code: &str,
1884 ) -> Result<LoginTokens, HingeError> {
1885 let url = format!("{}/auth/device/validate", self.settings.base_url);
1886 let body = json!({
1887 "installId": self.install_id,
1888 "code": email_code,
1889 "caseId": case_id,
1890 "deviceId": self.device_id,
1891 });
1892 let res = self.http_post(&url, &body).await?;
1893 if !res.status().is_success() {
1894 return Err(HingeError::Http(format!("status {}", res.status())));
1895 }
1896 let t = self.parse_response::<HingeAuthToken>(res).await?;
1897 self.hinge_auth = Some(t);
1898 let _ = self.authenticate_with_sendbird().await; Ok(LoginTokens {
1900 hinge_auth_token: self.hinge_auth.clone(),
1901 sendbird_auth_token: self.sendbird_auth.clone(),
1902 })
1903 }
1904
1905 pub fn save_session(&self, path: &str) -> Result<(), HingeError> {
1906 let session = json!({
1907 "phoneNumber": self.phone_number,
1908 "deviceId": self.device_id,
1909 "installId": self.install_id,
1910 "sessionId": self.session_id,
1911 "installed": self.installed,
1912 "hingeAuth": self.hinge_auth,
1913 "sendbirdAuth": self.sendbird_auth,
1914 "sendbirdSessionKey": self.sendbird_session_key,
1915 });
1916 let data =
1917 serde_json::to_string_pretty(&session).map_err(|e| HingeError::Serde(e.to_string()))?;
1918 self.storage
1919 .write_string(path, &data)
1920 .map_err(|e| HingeError::Storage(e.to_string()))?;
1921 Ok(())
1922 }
1923
1924 pub fn load_session(&mut self, path: &str) -> Result<(), HingeError> {
1925 if !self.storage.exists(path) {
1926 return Ok(());
1927 }
1928 let data = self
1929 .storage
1930 .read_to_string(path)
1931 .map_err(|e| HingeError::Storage(e.to_string()))?;
1932 let v: serde_json::Value =
1933 serde_json::from_str(&data).map_err(|e| HingeError::Serde(e.to_string()))?;
1934 if let Some(s) = v.get("phoneNumber").and_then(|v| v.as_str()) {
1935 self.phone_number = s.to_string();
1936 }
1937 if let Some(s) = v.get("deviceId").and_then(|v| v.as_str()) {
1938 self.device_id = s.to_string();
1939 }
1940 if let Some(s) = v.get("installId").and_then(|v| v.as_str()) {
1941 self.install_id = s.to_string();
1942 }
1943 if let Some(s) = v.get("sessionId").and_then(|v| v.as_str()) {
1944 self.session_id = s.to_string();
1945 }
1946 if let Some(b) = v.get("installed").and_then(|v| v.as_bool()) {
1947 self.installed = b;
1948 }
1949 if let Some(t) = v.get("hingeAuth").cloned() {
1950 self.hinge_auth = serde_json::from_value(t).ok();
1951 }
1952 if let Some(t) = v.get("sendbirdAuth").cloned() {
1953 self.sendbird_auth = serde_json::from_value(t).ok();
1954 }
1955 if let Some(k) = v.get("sendbirdSessionKey").and_then(|v| v.as_str()) {
1956 self.sendbird_session_key = Some(k.to_string());
1957 }
1958 Ok(())
1959 }
1960
1961 pub fn load_tokens_secure(&mut self) -> Result<(), HingeError> {
1962 if let Some(store) = &self.secret_store {
1963 if let Some(v) = store
1964 .get_secret("hinge_auth")
1965 .map_err(|e| HingeError::Storage(e.to_string()))?
1966 {
1967 self.hinge_auth = serde_json::from_str(&v).ok();
1968 }
1969 if let Some(v) = store
1970 .get_secret("sendbird_auth")
1971 .map_err(|e| HingeError::Storage(e.to_string()))?
1972 {
1973 self.sendbird_auth = serde_json::from_str(&v).ok();
1974 }
1975 }
1976 Ok(())
1977 }
1978
1979 pub fn with_persistence(
1980 mut self,
1981 session_path: Option<String>,
1982 cache_dir: Option<PathBuf>,
1983 auto_persist: bool,
1984 ) -> Self {
1985 self.session_path = session_path;
1986 self.cache_dir = cache_dir;
1987 self.auto_persist = auto_persist;
1988 if let Some(path) = self.session_path.clone() {
1989 let _ = self.load_session(&path);
1990 }
1991 if let Some(dir) = &self.cache_dir {
1992 let rec_path = dir.join(format!("recommendations_{}.json", self.session_id));
1993 let _ = self.load_recommendations(rec_path.to_string_lossy().as_ref());
1994 }
1995 self
1996 }
1997
1998 fn recs_cache_path(&self) -> Option<String> {
1999 self.cache_dir.as_ref().map(|d| {
2000 d.join(format!("recommendations_{}.json", self.session_id))
2001 .to_string_lossy()
2002 .into_owned()
2003 })
2004 }
2005
2006 fn prompts_cache_path(&self) -> Option<String> {
2007 self.cache_dir
2008 .as_ref()
2009 .map(|d| d.join("prompts_cache.json").to_string_lossy().into_owned())
2010 }
2011
2012 pub async fn fetch_prompts(&mut self) -> Result<PromptsResponse, HingeError> {
2013 if self.auto_persist
2014 && let Some(path) = self.prompts_cache_path()
2015 && Path::new(&path).exists()
2016 && let Ok(text) = std::fs::read_to_string(&path)
2017 && let Ok(val) = serde_json::from_str::<PromptsResponse>(&text)
2018 {
2019 return Ok(val);
2020 }
2021 let url = format!("{}/prompts", self.settings.base_url);
2022 let payload = self.prompt_payload().await;
2023 let res = self.http_post(&url, &payload).await?;
2024 let body = self.parse_response::<PromptsResponse>(res).await?;
2025 if self.auto_persist
2026 && let Some(path) = self.prompts_cache_path()
2027 {
2028 let _ = std::fs::write(
2029 &path,
2030 serde_json::to_string_pretty(&body).unwrap_or("{}".into()),
2031 );
2032 }
2033 Ok(body)
2034 }
2035
2036 pub async fn fetch_prompts_manager(&mut self) -> Result<HingePromptsManager, HingeError> {
2037 let resp = self.fetch_prompts().await?;
2038 Ok(HingePromptsManager::new(resp))
2039 }
2040
2041 pub async fn get_prompt_text(&mut self, prompt_id: &str) -> Result<String, HingeError> {
2042 let mgr = self.fetch_prompts_manager().await?;
2043 Ok(mgr.get_prompt_display_text(prompt_id))
2044 }
2045
2046 pub async fn search_prompts(&mut self, query: &str) -> Result<Vec<Prompt>, HingeError> {
2047 let mgr = self.fetch_prompts_manager().await?;
2048 let items = mgr.search_prompts(query);
2049 Ok(items.into_iter().cloned().collect())
2050 }
2051
2052 pub async fn get_prompts_by_category(
2053 &mut self,
2054 category_slug: &str,
2055 ) -> Result<Vec<Prompt>, HingeError> {
2056 let mgr = self.fetch_prompts_manager().await?;
2057 let items = mgr.get_prompts_by_category(category_slug);
2058 Ok(items.into_iter().cloned().collect())
2059 }
2060
2061 pub async fn get_recommendations(&mut self) -> Result<RecommendationsResponse, HingeError> {
2062 self.get_recommendations_v2_params(crate::models::RecsV2Params {
2063 new_here: false,
2064 active_today: false,
2065 })
2066 .await
2067 }
2068
2069 pub async fn get_recommendations_v2_params(
2070 &mut self,
2071 params: crate::models::RecsV2Params,
2072 ) -> Result<RecommendationsResponse, HingeError> {
2073 let url = format!("{}/rec/v2", self.settings.base_url);
2074 let identity_id = self
2075 .hinge_auth
2076 .as_ref()
2077 .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
2078 .identity_id
2079 .clone();
2080
2081 use serde::Serialize;
2082 #[derive(Serialize)]
2083 #[serde(rename_all = "camelCase")]
2084 struct Body {
2085 player_id: String,
2086 new_here: bool,
2087 active_today: bool,
2088 }
2089
2090 let body = Body {
2091 player_id: identity_id,
2092 new_here: params.new_here,
2093 active_today: params.active_today,
2094 };
2095
2096 let body_json =
2097 serde_json::to_value(&body).map_err(|e| HingeError::Serde(e.to_string()))?;
2098
2099 let fetch_count = self.recs_fetch_config.multi_fetch_count.max(1);
2100 let min_delay = Duration::from_millis(self.recs_fetch_config.request_delay_ms);
2101 let mut aggregated: Option<RecommendationsResponse> = None;
2102 let mut completed_calls = 0usize;
2103 let mut rate_limit_attempts = 0usize;
2104 let max_rate_limit_retries = self.recs_fetch_config.rate_limit_retries;
2105 let base_backoff_ms = self.recs_fetch_config.rate_limit_backoff_ms.max(1);
2106
2107 while completed_calls < fetch_count {
2108 if let Some(last_call) = self.last_recs_v2_call {
2109 let elapsed = last_call.elapsed();
2110 if elapsed < min_delay {
2111 sleep(min_delay - elapsed).await;
2112 }
2113 }
2114
2115 let res = self.http_post(&url, &body_json).await?;
2116 self.last_recs_v2_call = Some(Instant::now());
2117
2118 let status = res.status();
2119 if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE
2120 {
2121 rate_limit_attempts += 1;
2122 if rate_limit_attempts > max_rate_limit_retries {
2123 log::warn!(
2124 "[rec/v2] rate limited after {} retries; returning aggregated data",
2125 rate_limit_attempts
2126 );
2127 break;
2128 }
2129 let exponent = rate_limit_attempts.saturating_sub(1) as u32;
2130 let factor = 1u64
2131 .checked_shl(exponent)
2132 .filter(|&v| v > 0)
2133 .unwrap_or(u64::MAX);
2134 let backoff = base_backoff_ms.saturating_mul(factor);
2135 log::warn!(
2136 "[rec/v2] rate limited (status {}). backing off {} ms before retry (attempt {}/{})",
2137 status,
2138 backoff,
2139 rate_limit_attempts,
2140 max_rate_limit_retries
2141 );
2142 sleep(Duration::from_millis(backoff)).await;
2143 continue;
2144 }
2145
2146 rate_limit_attempts = 0;
2147
2148 let response = self.parse_response::<RecommendationsResponse>(res).await?;
2149 if let Some(existing) = aggregated.as_mut() {
2150 merge_recommendation_responses(existing, response);
2151 } else {
2152 aggregated = Some(response);
2153 }
2154
2155 completed_calls += 1;
2156 }
2157
2158 let mut out = aggregated.unwrap_or_else(|| RecommendationsResponse {
2159 feeds: Vec::new(),
2160 active_pills: None,
2161 cache_control: None,
2162 });
2163
2164 normalize_recommendations_response(&mut out);
2165
2166 if self.auto_persist {
2167 match self.recs_cache_path() {
2168 Some(path) => {
2169 let _ = self.apply_recommendations_and_save(&mut out, Some(&path));
2170 }
2171 None => {
2172 let _ = self.apply_recommendations_and_save(&mut out, None);
2173 }
2174 }
2175 }
2176 Ok(out)
2177 }
2178
2179 pub fn apply_recommendations_and_save(
2180 &mut self,
2181 recs: &mut RecommendationsResponse,
2182 path: Option<&str>,
2183 ) -> Result<(), HingeError> {
2184 for feed in &mut recs.feeds {
2185 for subj in &mut feed.subjects {
2186 if subj.origin.is_none() {
2187 subj.origin = Some(feed.origin.clone());
2188 }
2189 if !self.recommendations.contains_key(&subj.subject_id) {
2190 self.recommendations
2191 .insert(subj.subject_id.clone(), subj.clone());
2192 }
2193 }
2194 }
2195 if let Some(p) = path {
2196 self.save_recommendations(p)?;
2197 }
2198 Ok(())
2199 }
2200
2201 pub async fn get_self_profile(&self) -> Result<SelfProfileResponse, HingeError> {
2202 let url = format!("{}/user/v3", self.settings.base_url);
2203 let res = self.http_get(&url).await?;
2204 self.parse_response::<SelfProfileResponse>(res).await
2205 }
2206
2207 pub async fn get_self_content(&self) -> Result<SelfContentResponse, HingeError> {
2208 let url = format!("{}/content/v2", self.settings.base_url);
2209 let res = self.http_get(&url).await?;
2210 self.parse_response::<SelfContentResponse>(res).await
2211 }
2212
2213 pub async fn get_self_preferences(&self) -> Result<PreferencesResponse, HingeError> {
2214 let url = format!("{}/preference/v2/selected", self.settings.base_url);
2215 let res = self.http_get(&url).await?;
2216 self.parse_response::<PreferencesResponse>(res).await
2217 }
2218
2219 pub async fn get_like_limit(&self) -> Result<LikeLimit, HingeError> {
2220 let url = format!("{}/likelimit", self.settings.base_url);
2221 let res = self
2222 .http
2223 .get(url)
2224 .headers(self.default_headers()?)
2225 .send()
2226 .await?;
2227 if !res.status().is_success() {
2228 return Err(HingeError::Http(format!("status {}", res.status())));
2229 }
2230 self.parse_response(res).await
2231 }
2232
2233 pub async fn get_likes_v2(&self) -> Result<LikesV2Response, HingeError> {
2234 let url = format!("{}/like/v2", self.settings.base_url);
2235 let res = self.http_get(&url).await?;
2236 self.parse_response(res).await
2237 }
2238
2239 pub async fn get_like_subject(
2240 &self,
2241 subject_id: &str,
2242 ) -> Result<crate::models::LikeItemV2, HingeError> {
2243 let url = format!("{}/like/subject/{}", self.settings.base_url, subject_id);
2244 let res = self.http_get(&url).await?;
2245 self.parse_response(res).await
2246 }
2247
2248 pub async fn get_match_note(&self, subject_id: &str) -> Result<MatchNoteResponse, HingeError> {
2249 let url = format!(
2250 "{}/connection/v2/matchnote/{}",
2251 self.settings.base_url, subject_id
2252 );
2253 let res = self.http_get(&url).await?;
2254 self.parse_response(res).await
2255 }
2256
2257 pub async fn get_likes_v2_raw(&self) -> Result<serde_json::Value, HingeError> {
2259 let url = format!("{}/like/v2", self.settings.base_url);
2260 let res = self.http_get(&url).await?;
2261 let status = res.status();
2262 let headers = res.headers().clone();
2263 let text = res
2264 .text()
2265 .await
2266 .map_err(|e| HingeError::Http(e.to_string()))?;
2267 if !status.is_success() {
2268 log::error!("HTTP Error {}: {}", status, text);
2269 return Err(HingeError::Http(format!("status {}: {}", status, text)));
2270 }
2271 let val: serde_json::Value =
2272 serde_json::from_str(&text).map_err(|e| HingeError::Serde(e.to_string()))?;
2273 log_response(status, &headers, Some(&val));
2274 Ok(val)
2275 }
2276
2277 pub async fn get_profiles_public_raw_unfiltered(
2279 &self,
2280 ids: Vec<String>,
2281 ) -> Result<serde_json::Value, HingeError> {
2282 let url = format!(
2283 "{}/user/v3/public?ids={}",
2284 self.settings.base_url,
2285 ids.join(",")
2286 );
2287 let res = self.http_get(&url).await?;
2288 let status = res.status();
2289 let headers = res.headers().clone();
2290 let text = res
2291 .text()
2292 .await
2293 .map_err(|e| HingeError::Http(e.to_string()))?;
2294 if !status.is_success() {
2295 log::error!("HTTP Error {}: {}", status, text);
2296 return Err(HingeError::Http(format!("status {}: {}", status, text)));
2297 }
2298 let val: serde_json::Value =
2299 serde_json::from_str(&text).map_err(|e| HingeError::Serde(e.to_string()))?;
2300 log_response(status, &headers, Some(&val));
2301 Ok(val)
2302 }
2303
2304 pub async fn get_content_public_raw_unfiltered(
2306 &self,
2307 ids: Vec<String>,
2308 ) -> Result<serde_json::Value, HingeError> {
2309 let url = format!(
2310 "{}/content/v2/public?ids={}",
2311 self.settings.base_url,
2312 ids.join(",")
2313 );
2314 let res = self.http_get(&url).await?;
2315 let status = res.status();
2316 let headers = res.headers().clone();
2317 let text = res
2318 .text()
2319 .await
2320 .map_err(|e| HingeError::Http(e.to_string()))?;
2321 if !status.is_success() {
2322 log::error!("HTTP Error {}: {}", status, text);
2323 return Err(HingeError::Http(format!("status {}: {}", status, text)));
2324 }
2325 let val: serde_json::Value =
2326 serde_json::from_str(&text).map_err(|e| HingeError::Serde(e.to_string()))?;
2327 log_response(status, &headers, Some(&val));
2328 Ok(val)
2329 }
2330
2331 pub async fn get_profiles(
2332 &self,
2333 user_ids: Vec<String>,
2334 ) -> Result<Vec<PublicUserProfile>, HingeError> {
2335 let chunks = self.prepare_user_id_chunks(user_ids);
2336 if chunks.is_empty() {
2337 return Ok(Vec::new());
2338 }
2339 let mut aggregated: Vec<PublicUserProfile> = Vec::new();
2340 for batch in chunks {
2341 let url = format!(
2342 "{}/user/v3/public?ids={}",
2343 self.settings.base_url,
2344 batch.join(",")
2345 );
2346 let res = self.http_get(&url).await?;
2347 let mut part: Vec<PublicUserProfile> = self.parse_response(res).await?;
2348 aggregated.append(&mut part);
2349 }
2350 Ok(aggregated)
2351 }
2352
2353 pub async fn get_profile_content(
2354 &self,
2355 user_ids: Vec<String>,
2356 ) -> Result<Vec<ProfileContentFull>, HingeError> {
2357 let chunks = self.prepare_user_id_chunks(user_ids);
2358 if chunks.is_empty() {
2359 return Ok(Vec::new());
2360 }
2361 let mut aggregated: Vec<ProfileContentFull> = Vec::new();
2362 for batch in chunks {
2363 let url = format!(
2364 "{}/content/v2/public?ids={}",
2365 self.settings.base_url,
2366 batch.join(",")
2367 );
2368 let res = self.http_get(&url).await?;
2369 let mut part: Vec<ProfileContentFull> = self.parse_response(res).await?;
2370 aggregated.append(&mut part);
2371 }
2372 Ok(aggregated)
2373 }
2374
2375 pub async fn skip(&mut self, input: SkipInput) -> Result<serde_json::Value, HingeError> {
2378 let url = format!("{}/rate/v2/initiate", self.settings.base_url);
2379 let payload = CreateRate {
2380 rating_id: Uuid::new_v4().to_string().to_uppercase(),
2381 hcm_run_id: None,
2382 session_id: self.session_id.clone(),
2383 content: None,
2385 created: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
2386 rating_token: input.rating_token,
2387 initiated_with: None,
2388 rating: "skip".into(),
2389 has_pairing: false,
2390 origin: Some(input.origin.unwrap_or_else(|| "compatibles".into())),
2391 subject_id: input.subject_id.clone(),
2392 };
2393 let res = self
2394 .http_post(&url, &serde_json::to_value(&payload).unwrap())
2395 .await?;
2396 if !res.status().is_success() {
2397 return Err(HingeError::Http(format!("status {}", res.status())));
2398 }
2399 let body = res.json::<serde_json::Value>().await?;
2400 self.remove_recommendation(&input.subject_id);
2401 if self.auto_persist
2402 && let Some(path) = self.recs_cache_path()
2403 {
2404 let _ = self.save_recommendations(&path);
2405 }
2406 Ok(body)
2407 }
2408
2409 pub async fn rate_user(&mut self, input: RateInput) -> Result<LikeResponse, HingeError> {
2410 let mut hcm_run_id: Option<String> = None;
2411 if let Some(text) = input.comment.as_deref() {
2412 let run_id = self.run_text_review(text, &input.subject_id).await?;
2413 hcm_run_id = Some(run_id);
2414 }
2415 let prompt_answer = input.answer_text.clone().unwrap_or_default();
2416 let prompt_question = input.question_text.clone().unwrap_or_default();
2417 let prompt_content_id = input.content_id.clone();
2418
2419 let content = if let Some(photo) = input.photo {
2420 let PhotoAssetInput {
2421 url,
2422 content_id,
2423 cdn_id,
2424 bounding_box,
2425 selfie_verified,
2426 } = photo;
2427 Some(CreateRateContent {
2428 comment: input.comment.clone(),
2429 photo: Some(PhotoAsset {
2430 id: None,
2431 url,
2432 cdn_id,
2433 content_id,
2434 prompt_id: None,
2435 caption: None,
2436 width: None,
2437 height: None,
2438 video_url: None,
2439 selfie_verified,
2440 bounding_box,
2441 location: None,
2442 source: None,
2443 source_id: None,
2444 p_hash: None,
2445 }),
2446 prompt: None,
2447 })
2448 } else {
2449 let prompt = CreateRateContentPrompt {
2450 answer: prompt_answer,
2451 content_id: prompt_content_id,
2452 question: prompt_question,
2453 };
2454 Some(CreateRateContent {
2455 comment: input.comment.clone(),
2456 photo: None,
2457 prompt: Some(prompt),
2458 })
2459 };
2460 let payload = CreateRate {
2461 rating_id: Uuid::new_v4().to_string().to_uppercase(),
2462 hcm_run_id,
2463 session_id: self.session_id.clone(),
2464 content,
2465 created: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
2466 rating_token: input.rating_token,
2467 initiated_with: Some(if input.use_superlike.unwrap_or(false) {
2468 "superlike".into()
2469 } else {
2470 "standard".into()
2471 }),
2472 rating: if input.comment.is_some() {
2473 "note".into()
2474 } else {
2475 "like".into()
2476 },
2477 has_pairing: false,
2478 origin: Some(input.origin.unwrap_or_else(|| "compatibles".into())),
2479 subject_id: input.subject_id,
2480 };
2481 let url = format!("{}/rate/v2/initiate", self.settings.base_url);
2482 let res = self
2483 .http_post(&url, &serde_json::to_value(&payload).unwrap())
2484 .await?;
2485 if !res.status().is_success() {
2486 return Err(HingeError::Http(format!("status {}", res.status())));
2487 }
2488 let body = self.parse_response::<LikeResponse>(res).await?;
2489 if self.auto_persist
2490 && let Some(path) = self.recs_cache_path()
2491 {
2492 let _ = self.save_recommendations(&path);
2493 }
2494 Ok(body)
2495 }
2496
2497 pub async fn respond_rate(
2498 &self,
2499 mut payload: RateRespondRequest,
2500 ) -> Result<RateRespondResponse, HingeError> {
2501 if payload.rating_id.is_none() {
2503 payload.rating_id = Some(Uuid::new_v4().to_string().to_uppercase());
2504 }
2505
2506 if payload.session_id.is_none() {
2508 payload.session_id = Some(self.session_id.clone());
2509 }
2510
2511 if payload.created.is_none() {
2513 payload.created = Some(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string());
2514 }
2515
2516 let url = format!("{}/rate/v2/respond", self.settings.base_url);
2517 let body = serde_json::to_value(&payload).map_err(|e| HingeError::Serde(e.to_string()))?;
2518 let res = self.http_post(&url, &body).await?;
2519 self.parse_response(res).await
2520 }
2521
2522 pub async fn update_self_preferences(
2523 &self,
2524 preferences: Preferences,
2525 ) -> Result<serde_json::Value, HingeError> {
2526 let url = format!("{}/preference/v2/selected", self.settings.base_url);
2527
2528 let prefs_json = preferences_to_api_json(&preferences);
2530 let payload = serde_json::json!([prefs_json]);
2531
2532 let res = self.http_patch(&url, &payload).await?;
2533 if !res.status().is_success() {
2534 return Err(HingeError::Http(format!("status {}", res.status())));
2535 }
2536 let body = res.json::<serde_json::Value>().await?;
2537 Ok(body)
2538 }
2539
2540 pub async fn update_self_profile(
2541 &self,
2542 profile_updates: ProfileUpdate,
2543 ) -> Result<serde_json::Value, HingeError> {
2544 let url = format!("{}/user/v3", self.settings.base_url);
2545
2546 let profile_json = profile_update_to_api_json(&profile_updates);
2548 let payload = serde_json::json!({ "profile": profile_json });
2549
2550 let res = self.http_patch(&url, &payload).await?;
2551 if !res.status().is_success() {
2552 return Err(HingeError::Http(format!("status {}", res.status())));
2553 }
2554 let body = res.json::<serde_json::Value>().await?;
2555 Ok(body)
2556 }
2557
2558 pub async fn update_answers(
2559 &self,
2560 answers: Vec<AnswerContentPayload>,
2561 ) -> Result<serde_json::Value, HingeError> {
2562 let url = format!("{}/content/v1/answers", self.settings.base_url);
2563 let res = self
2564 .http
2565 .put(url)
2566 .headers(self.default_headers()?)
2567 .json(&answers)
2568 .send()
2569 .await?;
2570 if !res.status().is_success() {
2571 return Err(HingeError::Http(format!("status {}", res.status())));
2572 }
2573 let body = res.json::<serde_json::Value>().await?;
2574 Ok(body)
2575 }
2576
2577 pub async fn repeat_profiles(&mut self) -> Result<serde_json::Value, HingeError> {
2578 let url = format!("{}/user/repeat", self.settings.base_url);
2579 let res = self
2580 .http
2581 .get(url)
2582 .headers(self.default_headers()?)
2583 .send()
2584 .await?;
2585 if !res.status().is_success() {
2586 return Err(HingeError::Http(format!("status {}", res.status())));
2587 }
2588 let body = res.json::<serde_json::Value>().await?;
2589 if self.auto_persist
2590 && let Some(path) = self.recs_cache_path()
2591 {
2592 let _ = self.save_recommendations(&path);
2593 }
2594 Ok(body)
2595 }
2596
2597 async fn authenticate_with_sendbird(&mut self) -> Result<(), HingeError> {
2598 let _hinge = self
2599 .hinge_auth
2600 .as_ref()
2601 .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?;
2602 let url = format!("{}/message/authenticate", self.settings.base_url);
2603 let res = self
2604 .http
2605 .post(url)
2606 .headers(self.default_headers()?)
2607 .json(&json!({"refresh": false}))
2608 .send()
2609 .await?;
2610 if !res.status().is_success() {
2611 return Err(HingeError::Http(format!("status {}", res.status())));
2612 }
2613 let v = self.parse_response::<SendbirdAuthToken>(res).await?;
2614 self.sendbird_auth = Some(v);
2615 if self.auto_persist
2617 && let Some(path) = &self.session_path
2618 {
2619 let _ = self.save_session(path);
2620 }
2621 Ok(())
2622 }
2623
2624 async fn run_text_review(&self, text: &str, receiver_id: &str) -> Result<String, HingeError> {
2625 let url = format!("{}/flag/textreview", self.settings.base_url);
2626 let res = self
2627 .http
2628 .post(url)
2629 .headers(self.default_headers()?)
2630 .json(&json!({ "text": text, "receiverId": receiver_id }))
2631 .send()
2632 .await?;
2633 if !res.status().is_success() {
2634 return Err(HingeError::Http(format!("status {}", res.status())));
2635 }
2636 let v = res.json::<serde_json::Value>().await?;
2637 let run_id = v
2638 .get("hcmRunId")
2639 .and_then(|v| v.as_str())
2640 .unwrap_or("")
2641 .to_string();
2642 Ok(run_id)
2643 }
2644
2645 pub async fn is_session_valid(&mut self) -> Result<bool, HingeError> {
2646 if self.hinge_auth.is_none() {
2648 log::warn!("Hinge token is empty, session is invalid.");
2649 return Ok(false);
2650 }
2651
2652 if self.sendbird_auth.is_none() {
2654 log::warn!("Sendbird JWT is empty, reauthenticating...");
2655 if let Err(e) = self.authenticate_with_sendbird().await {
2656 log::error!("Failed to reauthenticate with Sendbird: {}", e);
2657 return Ok(false);
2658 }
2659 }
2660
2661 let now = Utc::now();
2662
2663 let hinge_token_valid = if let Some(hinge_auth) = &self.hinge_auth {
2665 hinge_auth.expires > now
2666 } else {
2667 false
2668 };
2669
2670 let sendbird_needs_refresh = if let Some(sb_auth) = &self.sendbird_auth {
2672 sb_auth.expires <= now
2673 } else {
2674 true
2675 };
2676
2677 if sendbird_needs_refresh {
2678 log::warn!("Sendbird JWT has expired or is missing, reauthenticating...");
2679 if let Err(e) = self.authenticate_with_sendbird().await {
2680 log::error!("Failed to reauthenticate with Sendbird: {}", e);
2681 return Ok(false);
2682 }
2683 }
2684
2685 let sendbird_token_valid = if let Some(sb_auth) = &self.sendbird_auth {
2687 sb_auth.expires > now
2688 } else {
2689 false
2690 };
2691
2692 let is_valid = hinge_token_valid && sendbird_token_valid;
2693 log::info!(
2694 "Session validity check: is_valid={}, hinge_token_valid={}, sendbird_token_valid={}",
2695 is_valid,
2696 hinge_token_valid,
2697 sendbird_token_valid
2698 );
2699
2700 Ok(is_valid)
2701 }
2702
2703 pub fn save_recommendations(&self, path: &str) -> Result<(), HingeError> {
2704 let data = serde_json::to_string_pretty(&self.recommendations)
2705 .map_err(|e| HingeError::Serde(e.to_string()))?;
2706 self.storage
2707 .write_string(path, &data)
2708 .map_err(|e| HingeError::Storage(e.to_string()))?;
2709 Ok(())
2710 }
2711
2712 pub fn load_recommendations(&mut self, path: &str) -> Result<(), HingeError> {
2713 if !self.storage.exists(path) {
2714 return Ok(());
2715 }
2716 let data = self
2717 .storage
2718 .read_to_string(path)
2719 .map_err(|e| HingeError::Storage(e.to_string()))?;
2720 self.recommendations =
2721 serde_json::from_str(&data).map_err(|e| HingeError::Serde(e.to_string()))?;
2722 Ok(())
2723 }
2724
2725 pub fn remove_recommendation(&mut self, subject_id: &str) {
2726 self.recommendations.remove(subject_id);
2727 }
2728
2729 pub async fn prompt_payload(&mut self) -> serde_json::Value {
2730 if !self.is_session_valid().await.unwrap_or(false) {
2732 return json!({});
2733 }
2734 let preferences = match self.get_self_preferences().await {
2735 Ok(v) => v,
2736 Err(_) => return json!({}),
2737 };
2738 let profile = match self.get_self_profile().await {
2739 Ok(v) => v,
2740 Err(_) => return json!({}),
2741 };
2742 let mut preferences_dict = serde_json::to_value(&preferences).unwrap_or(json!({}));
2743 let profile_dict = serde_json::to_value(&profile).unwrap_or(json!({}));
2744
2745 let selected: Vec<String> = preferences_dict
2746 .get("preferences")
2747 .and_then(|p| p.get("genderPreferences"))
2748 .and_then(|v| v.as_array())
2749 .map(|arr| {
2750 arr.iter()
2751 .filter_map(|x| x.as_u64().map(|n| n.to_string()))
2752 .collect()
2753 })
2754 .unwrap_or_default();
2755
2756 let keep_selected = |mut d: serde_json::Value| {
2757 if let serde_json::Value::Object(map) = &mut d
2758 && !selected.is_empty()
2759 {
2760 map.retain(|k, _| selected.contains(k));
2761 }
2762 d
2763 };
2764
2765 if let Some(obj) = preferences_dict
2766 .get_mut("preferences")
2767 .and_then(|p| p.get_mut("genderedHeightRanges"))
2768 {
2769 *obj = keep_selected(obj.clone());
2770 }
2771 if let Some(obj) = preferences_dict
2772 .get_mut("preferences")
2773 .and_then(|p| p.get_mut("genderedAgeRanges"))
2774 {
2775 *obj = keep_selected(obj.clone());
2776 }
2777
2778 if let Some(db) = preferences_dict
2779 .get_mut("preferences")
2780 .and_then(|p| p.get_mut("dealbreakers"))
2781 {
2782 if let Some(obj) = db.get_mut("genderedHeight") {
2783 *obj = keep_selected(obj.clone());
2784 }
2785 if let Some(obj) = db.get_mut("genderedAge") {
2786 *obj = keep_selected(obj.clone());
2787 }
2788 }
2789
2790 fn unwrap_visible(obj: &serde_json::Value) -> serde_json::Value {
2791 match obj {
2792 serde_json::Value::Object(m) => {
2793 if m.contains_key("value") && m.contains_key("visible") {
2794 unwrap_visible(&m["value"])
2795 } else {
2796 let mut out = serde_json::Map::new();
2797 for (k, v) in m.iter() {
2798 out.insert(k.clone(), unwrap_visible(v));
2799 }
2800 serde_json::Value::Object(out)
2801 }
2802 }
2803 serde_json::Value::Array(arr) => {
2804 serde_json::Value::Array(arr.iter().map(unwrap_visible).collect())
2805 }
2806 _ => obj.clone(),
2807 }
2808 }
2809
2810 let p = profile_dict
2811 .get("content")
2812 .map(unwrap_visible)
2813 .unwrap_or(json!({}));
2814 let loc_name = profile_dict
2815 .get("content")
2816 .and_then(|c| c.get("location"))
2817 .and_then(|l| l.get("name"))
2818 .cloned()
2819 .unwrap_or(json!(null));
2820
2821 let profile_payload = json!({
2822 "works": match p.get("works") { Some(v) if v.is_string() => json!([v]), _ => p.get("works").cloned().unwrap_or(json!([])) },
2823 "sexualOrientations": p.get("sexualOrientations").cloned().unwrap_or(json!([])),
2824 "didJustJoin": false,
2825 "smoking": p.get("smoking").cloned().unwrap_or(json!(null)),
2826 "selfieVerified": p.get("selfieVerified").cloned().unwrap_or(json!(false)),
2827 "politics": p.get("politics").cloned().unwrap_or(json!(null)),
2828 "relationshipTypesText": p.get("relationshipTypesText").cloned().unwrap_or(json!("")),
2829 "datingIntention": p.get("datingIntention").cloned().unwrap_or(json!(null)),
2830 "height": p.get("height").cloned().unwrap_or(json!(null)),
2831 "children": p.get("children").cloned().unwrap_or(json!(null)),
2832 "religions": p.get("religions").cloned().unwrap_or(json!([])),
2833 "relationshipTypes": p.get("relationshipTypeIds").cloned().unwrap_or(json!([])),
2834 "educations": p.get("educations").cloned().unwrap_or(json!([])),
2835 "age": p.get("age").cloned().unwrap_or(json!(null)),
2836 "jobTitle": p.get("jobTitle").cloned().unwrap_or(json!(null)),
2837 "birthday": p.get("birthday").cloned().unwrap_or(json!(null)),
2838 "drugs": p.get("drugs").cloned().unwrap_or(json!(null)),
2839 "content": json!({}),
2840 "hometown": p.get("hometown").cloned().unwrap_or(json!(null)),
2841 "firstName": p.get("firstName").cloned().unwrap_or(json!(null)),
2842 "familyPlans": p.get("familyPlans").cloned().unwrap_or(json!(null)),
2843 "location": json!({"name": loc_name}),
2844 "marijuana": p.get("marijuana").cloned().unwrap_or(json!(null)),
2845 "pets": p.get("pets").cloned().unwrap_or(json!([])),
2846 "datingIntentionText": p.get("datingIntentionText").cloned().unwrap_or(json!("")),
2847 "educationAttained": p.get("educationAttained").cloned().unwrap_or(json!(null)),
2848 "ethnicities": p.get("ethnicities").cloned().unwrap_or(json!([])),
2849 "pronouns": p.get("pronouns").cloned().unwrap_or(json!([])),
2850 "languagesSpoken": p.get("languagesSpoken").cloned().unwrap_or(json!([])),
2851 "lastName": p.get("lastName").cloned().unwrap_or(json!("")),
2852 "ethnicitiesText": p.get("ethnicitiesText").cloned().unwrap_or(json!("")),
2853 "drinking": p.get("drinking").cloned().unwrap_or(json!(null)),
2854 "userId": profile_dict.get("userId").cloned().unwrap_or(json!(null)),
2855 "genderIdentityId": p.get("genderIdentityId").cloned().unwrap_or(json!(null)),
2856 });
2857
2858 json!({
2859 "preferences": preferences_dict.get("preferences").cloned().unwrap_or(json!({})),
2860 "profile": profile_payload
2861 })
2862 }
2863
2864 pub async fn send_message(
2865 &self,
2866 mut payload: crate::models::SendMessagePayload,
2867 ) -> Result<serde_json::Value, HingeError> {
2868 let mut cloned = self.clone();
2871 let self_user_id = cloned
2872 .hinge_auth
2873 .as_ref()
2874 .ok_or_else(|| HingeError::Auth("hinge token missing".into()))?
2875 .identity_id
2876 .clone();
2877 let _channel_url = cloned
2878 .sendbird_get_or_create_dm_channel(&self_user_id, &payload.subject_id)
2879 .await
2880 .unwrap_or_else(|e| {
2881 log::warn!("sendbird get-or-create failed before send: {}", e);
2882 String::new()
2883 });
2884 if payload.dedup_id.is_none() {
2886 payload.dedup_id = Some(Uuid::new_v4().to_string().to_uppercase());
2887 }
2888
2889 let url = format!("{}/message/send", self.settings.base_url);
2890 let body = serde_json::to_value(&payload).map_err(|e| HingeError::Serde(e.to_string()))?;
2891 let res = self.http_post(&url, &body).await?;
2892 self.parse_response(res).await
2893 }
2894
2895 pub async fn evaluate_answer(
2896 &self,
2897 payload: AnswerEvaluateRequest,
2898 ) -> Result<serde_json::Value, HingeError> {
2899 let url = format!("{}/content/v1/answer/evaluate", self.settings.base_url);
2900 let body = serde_json::to_value(&payload).map_err(|e| HingeError::Serde(e.to_string()))?;
2901 let res = self.http_post(&url, &body).await?;
2902 self.parse_response(res).await
2903 }
2904
2905 pub async fn create_prompt_poll(
2906 &self,
2907 payload: CreatePromptPollRequest,
2908 ) -> Result<CreatePromptPollResponse, HingeError> {
2909 let url = format!("{}/content/v1/prompt_poll", self.settings.base_url);
2910 let body = serde_json::to_value(&payload).map_err(|e| HingeError::Serde(e.to_string()))?;
2911 let res = self.http_post(&url, &body).await?;
2912 self.parse_response(res).await
2913 }
2914
2915 pub async fn create_video_prompt(
2916 &self,
2917 payload: CreateVideoPromptRequest,
2918 ) -> Result<CreateVideoPromptResponse, HingeError> {
2919 let url = format!("{}/content/v1/video_prompt", self.settings.base_url);
2920 let body = serde_json::to_value(&payload).map_err(|e| HingeError::Serde(e.to_string()))?;
2921 let res = self.http_post(&url, &body).await?;
2922 self.parse_response(res).await
2923 }
2924
2925 pub async fn get_connections_v2(&self) -> Result<ConnectionsResponse, HingeError> {
2926 let url = format!("{}/connection/v2", self.settings.base_url);
2927 let res = self.http_get(&url).await?;
2928 self.parse_response(res).await
2929 }
2930
2931 pub async fn get_connection_detail(
2932 &self,
2933 subject_id: &str,
2934 ) -> Result<ConnectionDetailApi, HingeError> {
2935 let url = format!(
2936 "{}/connection/subject/{}",
2937 self.settings.base_url, subject_id
2938 );
2939 let res = self.http_get(&url).await?;
2940 self.parse_response(res).await
2941 }
2942
2943 pub async fn get_connection_match_note(
2944 &self,
2945 subject_id: &str,
2946 ) -> Result<MatchNoteResponse, HingeError> {
2947 let url = format!(
2948 "{}/connection/v2/matchnote/{}",
2949 self.settings.base_url, subject_id
2950 );
2951 let res = self.http_get(&url).await?;
2952 self.parse_response(res).await
2953 }
2954
2955 pub async fn get_standouts(&self) -> Result<StandoutsResponse, HingeError> {
2956 let url = format!("{}/standouts/v3", self.settings.base_url);
2957 let res = self.http_get(&url).await?;
2958 self.parse_response(res).await
2959 }
2960
2961 pub async fn delete_content(&self, content_ids: Vec<String>) -> Result<(), HingeError> {
2962 let url = format!(
2963 "{}/content/v1?ids={}",
2964 self.settings.base_url,
2965 content_ids.join(",")
2966 );
2967 let res = self
2968 .http
2969 .delete(url)
2970 .headers(self.default_headers()?)
2971 .send()
2972 .await?;
2973 if !res.status().is_success() {
2974 return Err(HingeError::Http(format!("status {}", res.status())));
2975 }
2976 Ok(())
2977 }
2978
2979 pub async fn get_content_settings(&self) -> Result<UserSettings, HingeError> {
2980 let url = format!("{}/content/v1/settings", self.settings.base_url);
2981 let res = self.http_get(&url).await?;
2982 self.parse_response(res).await
2983 }
2984
2985 pub async fn update_content_settings(
2986 &self,
2987 settings: UserSettings,
2988 ) -> Result<serde_json::Value, HingeError> {
2989 let url = format!("{}/content/v1/settings", self.settings.base_url);
2990 let payload =
2991 serde_json::to_value(&settings).map_err(|e| HingeError::Serde(e.to_string()))?;
2992 let res = self.http_patch(&url, &payload).await?;
2993 if !res.status().is_success() {
2994 return Err(HingeError::Http(format!("status {}", res.status())));
2995 }
2996 let body = res.json::<serde_json::Value>().await?;
2997 Ok(body)
2998 }
2999
3000 pub async fn get_auth_settings(&self) -> Result<AuthSettings, HingeError> {
3001 let url = format!("{}/auth/settings", self.settings.base_url);
3002 let res = self.http_get(&url).await?;
3003 self.parse_response(res).await
3004 }
3005
3006 pub async fn get_notification_settings(&self) -> Result<NotificationSettings, HingeError> {
3007 let url = format!("{}/notification/v1/settings", self.settings.base_url);
3008 let res = self.http_get(&url).await?;
3009 self.parse_response(res).await
3010 }
3011
3012 pub async fn get_user_traits(&self) -> Result<Vec<UserTrait>, HingeError> {
3013 let url = format!("{}/user/v2/traits", self.settings.base_url);
3014 let res = self.http_get(&url).await?;
3015 self.parse_response(res).await
3016 }
3017
3018 pub async fn get_account_info(&self) -> Result<AccountInfo, HingeError> {
3019 let url = format!("{}/store/v2/account", self.settings.base_url);
3020 let res = self.http_get(&url).await?;
3021 self.parse_response(res).await
3022 }
3023
3024 pub async fn get_export_status(&self) -> Result<ExportStatus, HingeError> {
3025 let url = format!("{}/user/export/status", self.settings.base_url);
3026 let res = self.http_get(&url).await?;
3027 self.parse_response(res).await
3028 }
3029
3030 pub async fn raw_hinge_json(
3031 &self,
3032 method: reqwest::Method,
3033 path_or_url: &str,
3034 body: Option<serde_json::Value>,
3035 ) -> Result<serde_json::Value, HingeError> {
3036 let url = if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
3037 path_or_url.to_string()
3038 } else {
3039 format!(
3040 "{}/{}",
3041 self.settings.base_url.trim_end_matches('/'),
3042 path_or_url.trim_start_matches('/')
3043 )
3044 };
3045 let headers = self.default_headers()?;
3046 log_request(method.as_str(), &url, &headers, body.as_ref());
3047 let mut request = self.http.request(method, &url).headers(headers);
3048 if let Some(body) = body {
3049 request = request.json(&body);
3050 }
3051 let res = request.send().await?;
3052 self.parse_response(res).await
3053 }
3054
3055 pub async fn raw_sendbird_json(
3056 &self,
3057 method: reqwest::Method,
3058 path_or_url: &str,
3059 body: Option<serde_json::Value>,
3060 ) -> Result<serde_json::Value, HingeError> {
3061 let url = if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
3062 path_or_url.to_string()
3063 } else {
3064 format!(
3065 "{}/v3/{}",
3066 self.settings.sendbird_api_url.trim_end_matches('/'),
3067 path_or_url.trim_start_matches('/')
3068 )
3069 };
3070 let mut headers = self.sendbird_headers()?;
3071 if body.is_some() {
3072 headers.insert(
3073 reqwest::header::CONTENT_TYPE,
3074 reqwest::header::HeaderValue::from_static("application/json"),
3075 );
3076 }
3077 log_request(method.as_str(), &url, &headers, body.as_ref());
3078 let mut request = self.http.request(method, &url).headers(headers);
3079 if let Some(body) = body {
3080 request = request.json(&body);
3081 }
3082 let res = request.send().await?;
3083 self.parse_response(res).await
3084 }
3085}
3086
3087fn merge_recommendation_responses(
3088 base: &mut RecommendationsResponse,
3089 mut additional: RecommendationsResponse,
3090) {
3091 let mut feed_index: HashMap<String, usize> = HashMap::new();
3092 for (idx, feed) in base.feeds.iter().enumerate() {
3093 feed_index.insert(feed.origin.clone(), idx);
3094 }
3095
3096 for feed in additional.feeds.drain(..) {
3097 if let Some(&idx) = feed_index.get(&feed.origin) {
3098 let existing_feed = &mut base.feeds[idx];
3099 let mut seen: HashSet<String> = existing_feed
3100 .subjects
3101 .iter()
3102 .map(|s| s.subject_id.clone())
3103 .collect();
3104 for mut subj in feed.subjects {
3105 if seen.insert(subj.subject_id.clone()) {
3106 if subj.origin.is_none() {
3107 subj.origin = Some(feed.origin.clone());
3108 }
3109 existing_feed.subjects.push(subj);
3110 }
3111 }
3112 if existing_feed.permission.is_none() {
3113 existing_feed.permission = feed.permission;
3114 }
3115 if existing_feed.preview.is_none() {
3116 existing_feed.preview = feed.preview;
3117 }
3118 } else {
3119 let mut new_feed = feed;
3120 for subj in &mut new_feed.subjects {
3121 if subj.origin.is_none() {
3122 subj.origin = Some(new_feed.origin.clone());
3123 }
3124 }
3125 feed_index.insert(new_feed.origin.clone(), base.feeds.len());
3126 base.feeds.push(new_feed);
3127 }
3128 }
3129
3130 match (&mut base.active_pills, additional.active_pills) {
3131 (Some(existing), Some(mut incoming)) => {
3132 let mut seen: HashSet<String> = existing.iter().map(|pill| pill.id.clone()).collect();
3133 for pill in incoming.drain(..) {
3134 if seen.insert(pill.id.clone()) {
3135 existing.push(pill);
3136 }
3137 }
3138 }
3139 (None, Some(pills)) => base.active_pills = Some(pills),
3140 _ => {}
3141 }
3142
3143 if base.cache_control.is_none() && additional.cache_control.is_some() {
3144 base.cache_control = additional.cache_control;
3145 }
3146}
3147
3148fn normalize_recommendations_response(response: &mut RecommendationsResponse) {
3149 let mut ordered_subjects: Vec<RecommendationSubject> = Vec::new();
3150 let mut seen = HashSet::new();
3151
3152 for feed in &response.feeds {
3153 for subj in &feed.subjects {
3154 if seen.insert(subj.subject_id.clone()) {
3155 let mut clone = subj.clone();
3156 if clone.origin.is_none() {
3157 clone.origin = Some(feed.origin.clone());
3158 }
3159 ordered_subjects.push(clone);
3160 }
3161 }
3162 }
3163
3164 let (permission, preview) = response
3165 .feeds
3166 .first()
3167 .map(|feed| (feed.permission.clone(), feed.preview.clone()))
3168 .unwrap_or((None, None));
3169
3170 let origin = response
3171 .feeds
3172 .first()
3173 .map(|feed| feed.origin.clone())
3174 .unwrap_or_else(|| "combined".to_string());
3175
3176 response.feeds = vec![crate::models::RecommendationsFeed {
3177 id: 0,
3178 origin,
3179 subjects: ordered_subjects,
3180 permission,
3181 preview,
3182 }];
3183}
3184
3185fn summarize_connection_initiation(
3186 connection: &ConnectionItem,
3187 self_user_id: &str,
3188 peer_user_id: &str,
3189 peer_display_name: &str,
3190) -> Option<Vec<String>> {
3191 let initiator_id = connection.initiator_id.trim();
3192 let initiator_label = if initiator_id.is_empty() {
3193 "Unknown".to_string()
3194 } else if initiator_id == self_user_id {
3195 "You".to_string()
3196 } else if initiator_id == peer_user_id {
3197 peer_display_name.to_string()
3198 } else {
3199 initiator_id.to_string()
3200 };
3201
3202 let mut lines = Vec::new();
3203 if let Some(with_label) = prettify_initiated_with(&connection.initiated_with) {
3204 lines.push(format!(
3205 "Conversation initiated by {} via {}.",
3206 initiator_label, with_label
3207 ));
3208 } else {
3209 lines.push(format!("Conversation initiated by {}.", initiator_label));
3210 }
3211
3212 let mut seen: HashSet<String> = HashSet::new();
3213 let mut detail_lines = Vec::new();
3214 for content in &connection.sent_content {
3215 for description in describe_connection_content_item(content) {
3216 if seen.insert(description.clone()) {
3217 detail_lines.push(description);
3218 }
3219 }
3220 }
3221
3222 for detail in detail_lines {
3223 lines.push(format!(" • {}", detail));
3224 }
3225
3226 Some(lines)
3227}
3228
3229fn describe_connection_content_item(item: &ConnectionContentItem) -> Vec<String> {
3230 let mut lines = Vec::new();
3231 if let Some(prompt) = &item.prompt {
3232 let question = prompt.question.trim();
3233 let answer = prompt.answer.trim();
3234 if !question.is_empty() && !answer.is_empty() {
3235 lines.push(format!("Prompt \"{}\" – \"{}\"", question, answer));
3236 } else if !question.is_empty() {
3237 lines.push(format!("Prompt \"{}\"", question));
3238 } else if !answer.is_empty() {
3239 lines.push(format!("Prompt answer \"{}\"", answer));
3240 }
3241 }
3242
3243 if let Some(comment) = &item.comment {
3244 let trimmed = comment.trim();
3245 if !trimmed.is_empty() {
3246 lines.push(format!("Comment: {}", trimmed));
3247 }
3248 }
3249
3250 if let Some(photo) = &item.photo {
3251 let caption = photo.caption.as_deref().map(str::trim).unwrap_or("");
3252 if !caption.is_empty() {
3253 lines.push(format!("Photo liked – {}", caption));
3254 } else {
3255 lines.push("Photo liked".to_string());
3256 }
3257 }
3258
3259 if let Some(video) = &item.video {
3260 if !video.url.trim().is_empty() {
3261 lines.push("Video shared".to_string());
3262 } else {
3263 lines.push("Video interaction".to_string());
3264 }
3265 }
3266
3267 lines
3268}
3269
3270fn prettify_initiated_with(value: &str) -> Option<String> {
3271 let trimmed = value.trim();
3272 if trimmed.is_empty() {
3273 return None;
3274 }
3275
3276 let words: Vec<String> = trimmed
3277 .split(['_', ' '])
3278 .filter(|part| !part.is_empty())
3279 .map(|part| {
3280 let mut chars = part.chars();
3281 if let Some(first) = chars.next() {
3282 let mut result = first.to_uppercase().collect::<String>();
3283 result.push_str(&chars.as_str().to_lowercase());
3284 result
3285 } else {
3286 String::new()
3287 }
3288 })
3289 .filter(|s| !s.is_empty())
3290 .collect();
3291
3292 if words.is_empty() {
3293 None
3294 } else {
3295 Some(words.join(" "))
3296 }
3297}
3298
3299fn render_profile(
3300 profile: Option<&PublicUserProfile>,
3301 content: Option<&ProfileContentFull>,
3302 prompts: Option<&HingePromptsManager>,
3303) -> String {
3304 let mut out = String::new();
3305
3306 if let Some(wrapper) = profile {
3307 let p = &wrapper.profile;
3308 let _ = writeln!(out, "Name: {}", p.first_name);
3309 if let Some(age) = p.age {
3310 let _ = writeln!(out, "Age: {}", age);
3311 }
3312 if let Some(height) = p.height {
3313 let _ = writeln!(out, "Height: {} cm", height);
3314 }
3315 if let Some(children) = label_from_map(CHILDREN_LABELS, p.children) {
3316 let _ = writeln!(out, "Children: {}", children);
3317 }
3318 if let Some(label) = label_from_map(DATING_LABELS, p.dating_intention) {
3319 let _ = writeln!(out, "Dating intention: {}", label);
3320 }
3321 if let Some(label) = label_from_map(DRINKING_LABELS, p.drinking) {
3322 let _ = writeln!(out, "Drinking: {}", label);
3323 }
3324 if let Some(label) = label_from_map(SMOKING_LABELS, p.smoking) {
3325 let _ = writeln!(out, "Smoking: {}", label);
3326 }
3327 if let Some(label) = label_from_map(MARIJUANA_LABELS, p.marijuana) {
3328 let _ = writeln!(out, "Marijuana: {}", label);
3329 }
3330 if let Some(label) = label_from_map(DRUG_LABELS, p.drugs) {
3331 let _ = writeln!(out, "Drugs: {}", label);
3332 }
3333 let relationship_labels =
3334 labels_from_map(RELATIONSHIP_TYPE_LABELS, &p.relationship_type_ids);
3335 if !relationship_labels.is_empty() {
3336 let _ = writeln!(
3337 out,
3338 "Relationship types: {}",
3339 relationship_labels.join(", ")
3340 );
3341 }
3342 if let Some(job) = p.job_title.as_ref().filter(|v| !v.trim().is_empty()) {
3343 let _ = writeln!(out, "Job title: {}", job);
3344 }
3345 if let Some(work) = p.works.as_ref().filter(|v| !v.trim().is_empty()) {
3346 let _ = writeln!(out, "Workplace: {}", work);
3347 }
3348 if let Some(level) = p.education_attained.as_ref() {
3349 let _ = writeln!(out, "Education level: {}", education_attained_label(level));
3350 }
3351 if let Some(schools) = p.educations.as_ref() {
3352 let entries: Vec<&str> = schools
3353 .iter()
3354 .map(|s| s.trim())
3355 .filter(|s| !s.is_empty())
3356 .collect();
3357 if !entries.is_empty() {
3358 let _ = writeln!(out, "Education: {}", entries.join(", "));
3359 }
3360 }
3361 if !p.location.name.trim().is_empty() {
3362 let _ = writeln!(out, "Location: {}", p.location.name);
3363 }
3364 out.push('\n');
3365 } else {
3366 out.push_str("Profile information unavailable.\n\n");
3367 }
3368
3369 if let Some(full) = content
3370 && !full.content.answers.is_empty()
3371 {
3372 out.push_str("Prompts:\n");
3373 for answer in &full.content.answers {
3374 let response = answer
3375 .response
3376 .as_ref()
3377 .map(|text| text.trim())
3378 .filter(|text| !text.is_empty());
3379 if let Some(resp) = response {
3380 let mut question: Option<String> = None;
3381
3382 if let Some(mgr) = prompts
3383 && let Some(prompt_id) = answer.prompt_id.as_ref()
3384 {
3385 let text = mgr.get_prompt_display_text(prompt_id);
3386 if !text.trim().is_empty() && text != "Unknown Question" {
3387 question = Some(text);
3388 }
3389 }
3390
3391 if question.is_none()
3392 && let Some(mgr) = prompts
3393 && let Some(question_id) = answer.question_id.as_ref()
3394 {
3395 let text = mgr.get_prompt_display_text(question_id);
3396 if !text.trim().is_empty() && text != "Unknown Question" {
3397 question = Some(text);
3398 }
3399 }
3400
3401 if question.is_none() {
3402 question = answer
3403 .content
3404 .as_ref()
3405 .map(|s| s.trim().to_string())
3406 .filter(|s| !s.is_empty());
3407 }
3408
3409 if question.is_none() {
3410 question = answer
3411 .question_id
3412 .as_ref()
3413 .map(|s| s.trim().to_string())
3414 .filter(|s| !s.is_empty());
3415 }
3416
3417 if question.is_none() {
3418 question = answer
3419 .prompt_id
3420 .as_ref()
3421 .map(|s| s.trim().to_string())
3422 .filter(|s| !s.is_empty());
3423 }
3424
3425 let question = question.unwrap_or_else(|| "Prompt".to_string());
3426 let _ = writeln!(out, "- {}: {}", question, resp);
3427 }
3428 }
3429 out.push('\n');
3430 }
3431
3432 out
3433}
3434
3435impl<S: Storage + Clone> HingeClient<S> {
3436 fn prepare_user_id_chunks(&self, user_ids: Vec<String>) -> Vec<Vec<String>> {
3437 fn is_user_id_like(id: &str) -> bool {
3439 if id.is_empty() {
3440 return false;
3441 }
3442 let trimmed = id.trim();
3443 if trimmed.chars().all(|c| c.is_ascii_digit()) {
3444 return true;
3445 }
3446 trimmed.len() == 32 && trimmed.chars().all(|c| c.is_ascii_hexdigit())
3447 }
3448
3449 let (mut accepted, mut dropped) = (Vec::new(), 0usize);
3450 let mut seen: HashSet<String> = HashSet::new();
3451 for raw in user_ids.into_iter() {
3452 let id = raw.trim().to_string();
3453 if is_user_id_like(&id) && seen.insert(id.clone()) {
3454 accepted.push(id);
3455 } else {
3456 dropped += 1;
3457 }
3458 }
3459
3460 if accepted.is_empty() {
3461 log::warn!("No valid user IDs to fetch (dropped {})", dropped);
3462 return Vec::new();
3463 }
3464 if dropped > 0 {
3465 log::debug!("Dropped {} non user-like IDs from public fetch", dropped);
3466 }
3467
3468 let batch_size = self.public_ids_batch_size.max(1);
3469 let mut out: Vec<Vec<String>> = Vec::new();
3470 let mut idx = 0usize;
3471 while idx < accepted.len() {
3472 let end = (idx + batch_size).min(accepted.len());
3473 out.push(accepted[idx..end].to_vec());
3474 idx = end;
3475 }
3476 if out.len() > 1 {
3477 log::info!(
3478 "Fetching public user data in {} batches of up to {} IDs",
3479 out.len(),
3480 batch_size
3481 );
3482 }
3483 out
3484 }
3485}
3486
3487#[cfg(test)]
3488mod tests {
3489 use super::*;
3490 use crate::models::{
3491 MessageData, SendMessagePayload, SendbirdChannelsResponse, SendbirdMessagesResponse,
3492 };
3493 use serde::Deserialize;
3494
3495 #[allow(dead_code)]
3496 #[derive(Debug, Deserialize)]
3497 struct PathAwareOuter {
3498 items: Vec<PathAwareInner>,
3499 }
3500
3501 #[allow(dead_code)]
3502 #[derive(Debug, Deserialize)]
3503 struct PathAwareInner {
3504 count: u32,
3505 }
3506
3507 #[test]
3508 fn response_deserialization_reports_json_path() {
3509 let err = parse_json_with_path::<PathAwareOuter>(r#"{"items":[{"count":"not-a-number"}]}"#)
3510 .expect_err("invalid nested field should fail");
3511 let message = err.to_string();
3512 assert!(message.contains("items[0].count"), "{message}");
3513 }
3514
3515 #[test]
3516 fn send_message_payload_serializes_camel_case() {
3517 let payload = SendMessagePayload {
3518 dedup_id: Some("dedup-1".to_string()),
3519 ays: false,
3520 match_message: true,
3521 message_type: "text".to_string(),
3522 message_data: MessageData {
3523 message: "hello".to_string(),
3524 },
3525 subject_id: "subject-1".to_string(),
3526 origin: "connection".to_string(),
3527 };
3528
3529 let value = serde_json::to_value(payload).expect("payload should serialize");
3530 assert_eq!(value["dedupId"], "dedup-1");
3531 assert_eq!(value["messageData"]["message"], "hello");
3532 assert_eq!(value["subjectId"], "subject-1");
3533 }
3534
3535 #[test]
3536 fn sendbird_channel_and_message_fixtures_deserialize() {
3537 let channels = parse_json_with_path::<SendbirdChannelsResponse>(
3538 r#"{
3539 "channels": [{
3540 "channel_url": "sendbird_group_channel_1",
3541 "members": [{"user_id": "user-1", "nickname": "A"}],
3542 "created_at": 1710000000000,
3543 "updated_at": 1710000000100,
3544 "last_message": {
3545 "type": "MESG",
3546 "message_id": 42,
3547 "message": "hello",
3548 "created_at": 1710000000000,
3549 "user": {"user_id": "user-1"},
3550 "channel_url": "sendbird_group_channel_1"
3551 }
3552 }]
3553 }"#,
3554 )
3555 .expect("channels fixture should deserialize");
3556 assert_eq!(channels.channels[0].channel_url, "sendbird_group_channel_1");
3557 assert_eq!(
3558 channels.channels[0]
3559 .last_message
3560 .as_ref()
3561 .expect("last message")
3562 .message_id,
3563 "42"
3564 );
3565
3566 let messages = parse_json_with_path::<SendbirdMessagesResponse>(
3567 r#"{
3568 "messages": [{
3569 "type": "MESG",
3570 "message_id": 43,
3571 "message": "reply",
3572 "created_at": 1710000000200,
3573 "user": {"user_id": "user-2", "nickname": "B"},
3574 "channel_url": "sendbird_group_channel_1"
3575 }]
3576 }"#,
3577 )
3578 .expect("messages fixture should deserialize");
3579 assert_eq!(messages.messages[0].message_id, "43");
3580 assert_eq!(messages.messages[0].user.user_id, "user-2");
3581 }
3582}