Skip to main content

beeper_desktop_api/models/
chat.rs

1//! Chat models
2
3use serde::{Deserialize, Deserializer, Serialize};
4use super::message::Message;
5use super::user::User;
6
7fn deserialize_optional_u64_from_string_or_number<'de, D>(
8    deserializer: D,
9) -> Result<Option<u64>, D::Error>
10where
11    D: Deserializer<'de>,
12{
13    #[derive(Deserialize)]
14    #[serde(untagged)]
15    enum U64OrString {
16        U64(u64),
17        String(String),
18    }
19
20    let value = Option::<U64OrString>::deserialize(deserializer)?;
21
22    match value {
23        None => Ok(None),
24        Some(U64OrString::U64(v)) => Ok(Some(v)),
25        Some(U64OrString::String(s)) => s
26            .parse::<u64>()
27            .map(Some)
28            .map_err(serde::de::Error::custom),
29    }
30}
31
32/// Chat participants with pagination
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Participants {
35    /// List of participants
36    pub items: Vec<User>,
37    /// Whether there are more participants not included
38    #[serde(rename = "hasMore")]
39    pub has_more: bool,
40    /// Total number of participants in the chat
41    pub total: u32,
42}
43
44/// A chat or conversation
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Chat {
47    /// Unique chat ID
48    pub id: String,
49    /// Local chat ID specific to this Beeper Desktop installation
50    #[serde(skip_serializing_if = "Option::is_none")]
51    #[serde(rename = "localChatID")]
52    pub local_chat_id: Option<String>,
53    /// Account ID this chat belongs to, generaly "whatsapp" etc.
54    #[serde(rename = "accountID")]
55    pub account_id: String,
56    /// Display-only human-readable network name (e.g., 'WhatsApp', 'Messenger')
57    pub network: String,
58    /// Display title of the chat
59    pub title: String,
60    /// Chat type: 'single' for direct messages, 'group' for group chats
61    #[serde(rename = "type")]
62    pub chat_type: String,
63    /// Chat participants information
64    pub participants: Participants,
65    /// Timestamp of last activity
66    #[serde(skip_serializing_if = "Option::is_none")]
67    #[serde(rename = "lastActivity")]
68    pub last_activity: Option<String>,
69    /// Number of unread messages
70    #[serde(rename = "unreadCount")]
71    pub unread_count: u32,
72    /// Last read message sortKey
73    #[serde(skip_serializing_if = "Option::is_none")]
74    #[serde(default)]
75    #[serde(rename = "lastReadMessageSortKey")]
76    #[serde(deserialize_with = "deserialize_optional_u64_from_string_or_number")]
77    pub last_read_message_sort_key: Option<u64>,
78    /// True if chat is archived
79    #[serde(rename = "isArchived")]
80    pub is_archived: bool,
81    /// True if chat notifications are muted
82    #[serde(rename = "isMuted")]
83    pub is_muted: bool,
84    /// True if chat is pinned
85    #[serde(rename = "isPinned")]
86    pub is_pinned: bool,
87    /// Last message preview for this chat, if available
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub preview: Option<Box<Message>>,
90}
91
92impl Chat {
93    /// Get a display name for the chat
94    /// 
95    /// For direct messages ('single'), returns the participant's full name or username.
96    /// For group chats, returns the chat title.
97    pub fn display_name(&self) -> String {
98        if self.chat_type == "single" {
99            // For direct messages, try to add the other person's name
100            if let Some(first_participant) = self.participants.items.iter().filter(|p| !p.is_self.unwrap_or(false)).next() {
101                if let Some(full_name) = &first_participant.full_name {
102                    return full_name.clone();
103                }
104                if let Some(username) = &first_participant.username {
105                    return username.clone();
106                }
107            }
108        }
109        
110        // Return the chat title as-is
111        self.title.clone()
112    }
113}
114
115/// Input for creating a chat
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct CreateChatInput {
118    /// Account ID to create chat on
119    #[serde(rename = "accountID")]
120    pub account_id: String,
121    /// Participant IDs for the chat
122    #[serde(rename = "participantIDs")]
123    pub participant_ids: Vec<String>,
124    /// Optional chat title for group chats
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub title: Option<String>,
127}
128
129/// Output from creating a chat
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct CreateChatOutput {
132    /// Newly created chat ID
133    #[serde(rename = "chatID")]
134    pub chat_id: String,
135}
136
137/// Output from listing chats
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ListChatsOutput {
140    /// List of chats
141    pub items: Vec<Chat>,
142    /// Whether there are more chats
143    #[serde(rename = "hasMore")]
144    pub has_more: bool,
145    /// Cursor for fetching older results
146    #[serde(skip_serializing_if = "Option::is_none")]
147    #[serde(rename = "oldestCursor")]
148    pub oldest_cursor: Option<String>,
149    /// Cursor for fetching newer results
150    #[serde(skip_serializing_if = "Option::is_none")]
151    #[serde(rename = "newestCursor")]
152    pub newest_cursor: Option<String>,
153}
154
155/// Output from searching chats
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct SearchChatsOutput {
158    /// Matching chats
159    pub items: Vec<Chat>,
160    /// Map of chat ID -> chat details
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub chats: Option<std::collections::HashMap<String, Chat>>,
163    /// Whether there are more results
164    #[serde(rename = "hasMore")]
165    pub has_more: bool,
166    /// Cursor for older results
167    #[serde(skip_serializing_if = "Option::is_none")]
168    #[serde(rename = "oldestCursor")]
169    pub oldest_cursor: Option<String>,
170    /// Cursor for newer results
171    #[serde(skip_serializing_if = "Option::is_none")]
172    #[serde(rename = "newestCursor")]
173    pub newest_cursor: Option<String>,
174}
175
176#[cfg(test)]
177mod tests {
178    use super::ListChatsOutput;
179
180    fn list_chats_payload_with_sort_key(sort_key_json: &str) -> String {
181        format!(
182            r#"{{
183                "items": [
184                    {{
185                        "id": "chat-1",
186                        "accountID": "account-1",
187                        "network": "WhatsApp",
188                        "title": "Alice",
189                        "type": "single",
190                        "participants": {{
191                            "items": [],
192                            "hasMore": false,
193                            "total": 0
194                        }},
195                        "unreadCount": 0,
196                        "lastReadMessageSortKey": {sort_key_json},
197                        "isArchived": false,
198                        "isMuted": false,
199                        "isPinned": false
200                    }}
201                ],
202                "hasMore": false
203            }}"#
204        )
205    }
206
207    #[test]
208    fn list_chats_deserializes_numeric_sort_key() {
209        let payload = list_chats_payload_with_sort_key("453400065536");
210        let output: ListChatsOutput = serde_json::from_str(&payload).expect("should parse");
211        assert_eq!(output.items[0].last_read_message_sort_key, Some(453400065536));
212    }
213
214    #[test]
215    fn list_chats_deserializes_string_sort_key() {
216        let payload = list_chats_payload_with_sort_key("\"453400065536\"");
217        let output: ListChatsOutput = serde_json::from_str(&payload).expect("should parse");
218        assert_eq!(output.items[0].last_read_message_sort_key, Some(453400065536));
219    }
220
221    #[test]
222    fn list_chats_deserializes_null_sort_key() {
223        let payload = list_chats_payload_with_sort_key("null");
224        let output: ListChatsOutput = serde_json::from_str(&payload).expect("should parse");
225        assert_eq!(output.items[0].last_read_message_sort_key, None);
226    }
227
228    #[test]
229    fn list_chats_deserializes_missing_sort_key() {
230        let payload = r#"{
231            "items": [
232                {
233                    "id": "chat-1",
234                    "accountID": "account-1",
235                    "network": "WhatsApp",
236                    "title": "Alice",
237                    "type": "single",
238                    "participants": {
239                        "items": [],
240                        "hasMore": false,
241                        "total": 0
242                    },
243                    "unreadCount": 0,
244                    "isArchived": false,
245                    "isMuted": false,
246                    "isPinned": false
247                }
248            ],
249            "hasMore": false
250        }"#;
251
252        let output: ListChatsOutput = serde_json::from_str(payload).expect("should parse");
253        assert_eq!(output.items[0].last_read_message_sort_key, None);
254    }
255
256    #[test]
257    fn list_chats_rejects_invalid_sort_key_string() {
258        let payload = list_chats_payload_with_sort_key("\"not-a-number\"");
259        let result: Result<ListChatsOutput, _> = serde_json::from_str(&payload);
260        assert!(result.is_err());
261    }
262}