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    #[serde(skip_serializing_if = "Option::is_none")]
58    #[serde(default)]
59    pub network: Option<String>,
60    /// Display title of the chat
61    pub title: String,
62    /// Chat type: 'single' for direct messages, 'group' for group chats
63    #[serde(rename = "type")]
64    pub chat_type: String,
65    /// Chat participants information
66    pub participants: Participants,
67    /// Timestamp of last activity
68    #[serde(skip_serializing_if = "Option::is_none")]
69    #[serde(rename = "lastActivity")]
70    pub last_activity: Option<String>,
71    /// Number of unread messages
72    #[serde(rename = "unreadCount")]
73    pub unread_count: u32,
74    /// Last read message sortKey
75    #[serde(skip_serializing_if = "Option::is_none")]
76    #[serde(default)]
77    #[serde(rename = "lastReadMessageSortKey")]
78    #[serde(deserialize_with = "deserialize_optional_u64_from_string_or_number")]
79    pub last_read_message_sort_key: Option<u64>,
80    /// True if chat is archived
81    #[serde(rename = "isArchived")]
82    pub is_archived: bool,
83    /// True if chat notifications are muted
84    #[serde(rename = "isMuted")]
85    pub is_muted: bool,
86    /// True if chat is pinned
87    #[serde(rename = "isPinned")]
88    pub is_pinned: bool,
89    /// Last message preview for this chat, if available
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub preview: Option<Box<Message>>,
92}
93
94impl Chat {
95    /// Get a display name for the chat
96    /// 
97    /// For direct messages ('single'), returns the participant's full name or username.
98    /// For group chats, returns the chat title.
99    pub fn display_name(&self) -> String {
100        if self.chat_type == "single" {
101            // For direct messages, try to add the other person's name
102            if let Some(first_participant) = self.participants.items.iter().filter(|p| !p.is_self.unwrap_or(false)).next() {
103                if let Some(full_name) = &first_participant.full_name {
104                    return full_name.clone();
105                }
106                if let Some(username) = &first_participant.username {
107                    return username.clone();
108                }
109            }
110        }
111        
112        // Return the chat title as-is
113        self.title.clone()
114    }
115}
116
117/// Input for creating a chat
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct CreateChatInput {
120    /// Account ID to create chat on
121    #[serde(rename = "accountID")]
122    pub account_id: String,
123    /// Participant IDs for the chat
124    #[serde(rename = "participantIDs")]
125    pub participant_ids: Vec<String>,
126    /// Optional chat title for group chats
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub title: Option<String>,
129}
130
131/// Output from creating a chat
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct CreateChatOutput {
134    /// Newly created chat ID
135    #[serde(rename = "chatID")]
136    pub chat_id: String,
137}
138
139/// Output from listing chats
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ListChatsOutput {
142    /// List of chats
143    pub items: Vec<Chat>,
144    /// Whether there are more chats
145    #[serde(rename = "hasMore")]
146    pub has_more: bool,
147    /// Cursor for fetching older results
148    #[serde(skip_serializing_if = "Option::is_none")]
149    #[serde(rename = "oldestCursor")]
150    pub oldest_cursor: Option<String>,
151    /// Cursor for fetching newer results
152    #[serde(skip_serializing_if = "Option::is_none")]
153    #[serde(rename = "newestCursor")]
154    pub newest_cursor: Option<String>,
155}
156
157/// Output from searching chats
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct SearchChatsOutput {
160    /// Matching chats
161    pub items: Vec<Chat>,
162    /// Map of chat ID -> chat details
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub chats: Option<std::collections::HashMap<String, Chat>>,
165    /// Whether there are more results
166    #[serde(rename = "hasMore")]
167    pub has_more: bool,
168    /// Cursor for older results
169    #[serde(skip_serializing_if = "Option::is_none")]
170    #[serde(rename = "oldestCursor")]
171    pub oldest_cursor: Option<String>,
172    /// Cursor for newer results
173    #[serde(skip_serializing_if = "Option::is_none")]
174    #[serde(rename = "newestCursor")]
175    pub newest_cursor: Option<String>,
176}
177
178#[cfg(test)]
179mod tests {
180    use super::ListChatsOutput;
181
182    fn list_chats_payload_with_sort_key(sort_key_json: &str) -> String {
183        format!(
184            r#"{{
185                "items": [
186                    {{
187                        "id": "chat-1",
188                        "accountID": "account-1",
189                        "network": "WhatsApp",
190                        "title": "Alice",
191                        "type": "single",
192                        "participants": {{
193                            "items": [],
194                            "hasMore": false,
195                            "total": 0
196                        }},
197                        "unreadCount": 0,
198                        "lastReadMessageSortKey": {sort_key_json},
199                        "isArchived": false,
200                        "isMuted": false,
201                        "isPinned": false
202                    }}
203                ],
204                "hasMore": false
205            }}"#
206        )
207    }
208
209    #[test]
210    fn list_chats_deserializes_numeric_sort_key() {
211        let payload = list_chats_payload_with_sort_key("453400065536");
212        let output: ListChatsOutput = serde_json::from_str(&payload).expect("should parse");
213        assert_eq!(output.items[0].last_read_message_sort_key, Some(453400065536));
214    }
215
216    #[test]
217    fn list_chats_deserializes_string_sort_key() {
218        let payload = list_chats_payload_with_sort_key("\"453400065536\"");
219        let output: ListChatsOutput = serde_json::from_str(&payload).expect("should parse");
220        assert_eq!(output.items[0].last_read_message_sort_key, Some(453400065536));
221    }
222
223    #[test]
224    fn list_chats_deserializes_null_sort_key() {
225        let payload = list_chats_payload_with_sort_key("null");
226        let output: ListChatsOutput = serde_json::from_str(&payload).expect("should parse");
227        assert_eq!(output.items[0].last_read_message_sort_key, None);
228    }
229
230    #[test]
231    fn list_chats_deserializes_missing_sort_key() {
232        let payload = r#"{
233            "items": [
234                {
235                    "id": "chat-1",
236                    "accountID": "account-1",
237                    "network": "WhatsApp",
238                    "title": "Alice",
239                    "type": "single",
240                    "participants": {
241                        "items": [],
242                        "hasMore": false,
243                        "total": 0
244                    },
245                    "unreadCount": 0,
246                    "isArchived": false,
247                    "isMuted": false,
248                    "isPinned": false
249                }
250            ],
251            "hasMore": false
252        }"#;
253
254        let output: ListChatsOutput = serde_json::from_str(payload).expect("should parse");
255        assert_eq!(output.items[0].last_read_message_sort_key, None);
256    }
257
258    #[test]
259    fn list_chats_rejects_invalid_sort_key_string() {
260        let payload = list_chats_payload_with_sort_key("\"not-a-number\"");
261        let result: Result<ListChatsOutput, _> = serde_json::from_str(&payload);
262        assert!(result.is_err());
263    }
264}