Skip to main content

hinge_rs/
client.rs

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 state (single connection)
62    sendbird_ws_cmd_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>, // READ, etc.
63    sendbird_ws_broadcast_tx: Option<tokio::sync::broadcast::Sender<String>>, // emits incoming frames
64    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    // Persistence config
72    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}