1use crate::models::{HingeAuthToken, RecommendationSubject, SendbirdAuthToken};
2use crate::settings::Settings;
3use crate::storage::{SecretStore, Storage};
4use reqwest::Client as Http;
5use std::path::PathBuf;
6use std::time::Instant;
7use uuid::Uuid;
8
9mod auth;
10mod chat;
11mod connections;
12mod likes;
13mod payload;
14mod persistence;
15mod profiles;
16mod prompts;
17mod ratings;
18mod raw;
19mod recommendations;
20mod render;
21mod sendbird;
22mod serde_helpers;
23mod settings;
24mod transport;
25
26pub const DEFAULT_PUBLIC_IDS_BATCH_SIZE: usize = 75;
27
28#[derive(Clone, Debug)]
29pub struct RecsFetchConfig {
30 pub multi_fetch_count: usize,
31 pub request_delay_ms: u64,
32 pub rate_limit_retries: usize,
33 pub rate_limit_backoff_ms: u64,
34}
35
36impl Default for RecsFetchConfig {
37 fn default() -> Self {
38 Self {
39 multi_fetch_count: 3,
40 request_delay_ms: 1_500,
41 rate_limit_retries: 3,
42 rate_limit_backoff_ms: 4_000,
43 }
44 }
45}
46
47#[derive(Clone)]
48pub struct HingeClient<S: Storage + Clone> {
49 http: Http,
50 pub settings: Settings,
51 pub storage: S,
52 secret_store: Option<std::sync::Arc<dyn SecretStore>>,
53 pub phone_number: String,
54 pub device_id: String,
55 pub install_id: String,
56 pub session_id: String,
57 pub installed: bool,
58 pub hinge_auth: Option<HingeAuthToken>,
59 pub sendbird_auth: Option<SendbirdAuthToken>,
60 pub sendbird_session_key: Option<String>,
61 sendbird_ws_cmd_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>, sendbird_ws_broadcast_tx: Option<tokio::sync::broadcast::Sender<String>>, sendbird_ws_connected: bool,
65 sendbird_ws_pending_requests: std::sync::Arc<
66 tokio::sync::Mutex<
67 std::collections::HashMap<String, tokio::sync::oneshot::Sender<serde_json::Value>>,
68 >,
69 >,
70 pub recommendations: std::collections::HashMap<String, RecommendationSubject>,
71 session_path: Option<String>,
73 cache_dir: Option<PathBuf>,
74 auto_persist: bool,
75 recs_fetch_config: RecsFetchConfig,
76 public_ids_batch_size: usize,
77 last_recs_v2_call: Option<Instant>,
78}
79
80impl<S: Storage + Clone> HingeClient<S> {
81 pub fn set_recs_fetch_config(&mut self, config: RecsFetchConfig) {
82 self.recs_fetch_config = config;
83 }
84
85 pub fn set_public_ids_batch_size(&mut self, batch_size: usize) {
86 self.public_ids_batch_size = batch_size.max(1);
87 }
88
89 pub fn new(phone_number: impl Into<String>, storage: S, settings: Option<Settings>) -> Self {
90 let settings = settings.unwrap_or_default();
91 Self {
92 http: Http::new(),
93 settings,
94 storage,
95 secret_store: None,
96 phone_number: phone_number.into(),
97 device_id: Uuid::new_v4().to_string().to_uppercase(),
98 install_id: Uuid::new_v4().to_string().to_uppercase(),
99 session_id: Uuid::new_v4().to_string().to_uppercase(),
100 installed: false,
101 hinge_auth: None,
102 sendbird_auth: None,
103 sendbird_session_key: None,
104 sendbird_ws_cmd_tx: None,
105 sendbird_ws_broadcast_tx: None,
106 sendbird_ws_connected: false,
107 sendbird_ws_pending_requests: std::sync::Arc::new(tokio::sync::Mutex::new(
108 std::collections::HashMap::new(),
109 )),
110 recommendations: std::collections::HashMap::new(),
111 session_path: None,
112 cache_dir: None,
113 auto_persist: false,
114 recs_fetch_config: RecsFetchConfig::default(),
115 public_ids_batch_size: DEFAULT_PUBLIC_IDS_BATCH_SIZE,
116 last_recs_v2_call: None,
117 }
118 }
119
120 pub fn with_secret_store(mut self, store: std::sync::Arc<dyn SecretStore>) -> Self {
121 self.secret_store = Some(store);
122 self
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::client::serde_helpers::parse_json_with_path;
130 use crate::models::{
131 MessageData, SendMessagePayload, SendbirdChannelsResponse, SendbirdMessagesResponse,
132 };
133 use crate::storage::FsStorage;
134 use chrono::Utc;
135 use serde::Deserialize;
136
137 #[allow(dead_code)]
138 #[derive(Debug, Deserialize)]
139 struct PathAwareOuter {
140 items: Vec<PathAwareInner>,
141 }
142
143 #[allow(dead_code)]
144 #[derive(Debug, Deserialize)]
145 struct PathAwareInner {
146 count: u32,
147 }
148
149 #[test]
150 fn response_deserialization_reports_json_path() {
151 let err = parse_json_with_path::<PathAwareOuter>(r#"{"items":[{"count":"not-a-number"}]}"#)
152 .expect_err("invalid nested field should fail");
153 let message = err.to_string();
154 assert!(message.contains("items[0].count"), "{message}");
155 }
156
157 #[test]
158 fn send_message_payload_serializes_camel_case() {
159 let payload = SendMessagePayload {
160 dedup_id: Some("dedup-1".to_string()),
161 ays: false,
162 match_message: true,
163 message_type: "text".to_string(),
164 message_data: MessageData {
165 message: "hello".to_string(),
166 },
167 subject_id: "subject-1".to_string(),
168 origin: "connection".to_string(),
169 };
170
171 let value = serde_json::to_value(payload).expect("payload should serialize");
172 assert_eq!(value["dedupId"], "dedup-1");
173 assert_eq!(value["messageData"]["message"], "hello");
174 assert_eq!(value["subjectId"], "subject-1");
175 }
176
177 #[test]
178 fn sendbird_channel_and_message_fixtures_deserialize() {
179 let channels = parse_json_with_path::<SendbirdChannelsResponse>(
180 r#"{
181 "channels": [{
182 "channel_url": "sendbird_group_channel_1",
183 "members": [{"user_id": "user-1", "nickname": "A"}],
184 "created_at": 1710000000000,
185 "updated_at": 1710000000100,
186 "last_message": {
187 "type": "MESG",
188 "message_id": 42,
189 "message": "hello",
190 "created_at": 1710000000000,
191 "user": {"user_id": "user-1"},
192 "channel_url": "sendbird_group_channel_1"
193 }
194 }]
195 }"#,
196 )
197 .expect("channels fixture should deserialize");
198 assert_eq!(channels.channels[0].channel_url, "sendbird_group_channel_1");
199 assert_eq!(
200 channels.channels[0]
201 .last_message
202 .as_ref()
203 .expect("last message")
204 .message_id,
205 "42"
206 );
207
208 let messages = parse_json_with_path::<SendbirdMessagesResponse>(
209 r#"{
210 "messages": [{
211 "type": "MESG",
212 "message_id": 43,
213 "message": "reply",
214 "created_at": 1710000000200,
215 "user": {"user_id": "user-2", "nickname": "B"},
216 "channel_url": "sendbird_group_channel_1"
217 }]
218 }"#,
219 )
220 .expect("messages fixture should deserialize");
221 assert_eq!(messages.messages[0].message_id, "43");
222 assert_eq!(messages.messages[0].user.user_id, "user-2");
223 }
224
225 #[test]
226 fn sendbird_headers_include_hinge_and_sendbird_identity() {
227 let mut client = HingeClient::new("+15555550123", FsStorage, None);
228 client.session_id = "session-id".to_string();
229 client.device_id = "device-id".to_string();
230 client.install_id = "install-id".to_string();
231 client.hinge_auth = Some(HingeAuthToken {
232 identity_id: "user-id".to_string(),
233 token: "hinge-token".to_string(),
234 expires: Utc::now(),
235 });
236 client.sendbird_auth = Some(SendbirdAuthToken {
237 token: "sendbird-token".to_string(),
238 expires: Utc::now(),
239 });
240 client.sendbird_session_key = Some("sendbird-session-key".to_string());
241
242 let headers = client
243 .sendbird_headers()
244 .expect("sendbird headers should build");
245
246 assert_eq!(headers["accept"], "*/*");
247 assert_eq!(headers["connection"], "keep-alive");
248 assert_eq!(headers["accept-language"], "en-GB");
249 assert_eq!(headers["x-session-key"], "session-id");
250 assert_eq!(headers["x-device-id"], "device-id");
251 assert_eq!(headers["x-install-id"], "install-id");
252 assert_eq!(headers["sb-user-id"], "user-id");
253 assert_eq!(headers["sb-access-token"], "sendbird-token");
254 assert_eq!(headers["session-key"], "sendbird-session-key");
255 }
256
257 #[test]
258 fn sendbird_sensitive_headers_are_redacted_in_logs() {
259 let mut client = HingeClient::new("+15555550123", FsStorage, None);
260 client.session_id = "session-id-secret".to_string();
261 client.device_id = "device-id-secret".to_string();
262 client.install_id = "install-id-secret".to_string();
263 client.hinge_auth = Some(HingeAuthToken {
264 identity_id: "user-id".to_string(),
265 token: "hinge-token-secret".to_string(),
266 expires: Utc::now(),
267 });
268 client.sendbird_auth = Some(SendbirdAuthToken {
269 token: "sendbird-token-secret".to_string(),
270 expires: Utc::now(),
271 });
272 client.sendbird_session_key = Some("sendbird-session-secret".to_string());
273
274 let headers = client
275 .sendbird_headers()
276 .expect("sendbird headers should build");
277 let rendered = crate::logging::format_headers(&headers);
278
279 assert!(rendered.contains("sb-access-token: ***REDACTED***"));
280 assert!(rendered.contains("session-key: ***REDACTED***"));
281 assert!(rendered.contains("x-session-key: ***REDACTED***"));
282 assert!(rendered.contains("x-device-id: ***cret"));
283 assert!(rendered.contains("x-install-id: ***cret"));
284 assert!(!rendered.contains("sendbird-token-secret"));
285 assert!(!rendered.contains("sendbird-session-secret"));
286 assert!(!rendered.contains("session-id-secret"));
287 }
288}