Skip to main content

nextcloud_talk/
client.rs

1//! Asynchronous client for Nextcloud Talk (Spreed) API.
2//!
3//! Official documentation:
4//! - [Nextcloud Talk API documentation](https://nextcloud-talk.readthedocs.io/en/latest/)
5
6use nextcloud_ocs::{OcsClient, OcsFormat, Query};
7use nextcloud_sdk_core::{ApiPath, AuthProvider, Error, Transport};
8use serde::Serialize;
9
10use crate::message::ChatMessage;
11use crate::room::Room;
12use crate::room_type::RoomType;
13
14/// Relative path to Nextcloud Talk room API beneath `/ocs/v2.php`.
15pub const ROOM_API_PATH: &str = "apps/spreed/api/v4/room";
16
17/// Relative path to Nextcloud Talk chat API beneath `/ocs/v2.php`.
18pub const CHAT_API_PATH: &str = "apps/spreed/api/v1/chat";
19
20/// Asynchronous client for Nextcloud Talk (Spreed) API.
21///
22/// Wraps an [`OcsClient`] by reference and provides high-level typed calls for:
23/// - Listing conversations / rooms (`/ocs/v2.php/apps/spreed/api/v4/room`)
24/// - Fetching conversation details by token (`/ocs/v2.php/apps/spreed/api/v4/room/{token}`)
25/// - Creating conversations (`/ocs/v2.php/apps/spreed/api/v4/room`)
26/// - Deleting conversations (`/ocs/v2.php/apps/spreed/api/v4/room/{token}`)
27/// - Listing chat messages (`/ocs/v2.php/apps/spreed/api/v1/chat/{token}`)
28/// - Sending chat messages (`/ocs/v2.php/apps/spreed/api/v1/chat/{token}`)
29#[derive(Clone, Copy, Debug)]
30pub struct TalkClient<'a, T: Transport, A: AuthProvider> {
31    ocs: &'a OcsClient<T, A>,
32}
33
34#[derive(Serialize)]
35#[serde(rename_all = "camelCase")]
36struct CreateRoomRequest<'a> {
37    room_type: RoomType,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    invite: Option<&'a str>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    room_name: Option<&'a str>,
42}
43
44#[derive(Serialize)]
45struct SendMessageRequest<'a> {
46    message: &'a str,
47}
48
49impl<'a, T: Transport, A: AuthProvider> TalkClient<'a, T, A> {
50    /// Binds a talk client to an underlying [`OcsClient`].
51    pub const fn new(ocs: &'a OcsClient<T, A>) -> Self {
52        Self { ocs }
53    }
54
55    /// Borrows the underlying [`OcsClient`].
56    pub const fn ocs(&self) -> &'a OcsClient<T, A> {
57        self.ocs
58    }
59
60    /// Lists all conversations the authenticated user belongs to.
61    ///
62    /// Sends `GET /ocs/v2.php/apps/spreed/api/v4/room`.
63    pub async fn list_rooms(&self) -> Result<Vec<Room>, Error> {
64        tracing::debug!("Listing Talk rooms");
65        let path = ApiPath::ocs(ROOM_API_PATH)?;
66        self.ocs
67            .get(path, Query::new().format(OcsFormat::Json))
68            .await
69    }
70
71    /// Retrieves conversation details for a specific room token.
72    ///
73    /// Sends `GET /ocs/v2.php/apps/spreed/api/v4/room/{token}`.
74    pub async fn get_room(&self, token: &str) -> Result<Room, Error> {
75        tracing::debug!(token, "Fetching Talk room details");
76        let path = ApiPath::ocs(format!("{ROOM_API_PATH}/{token}"))?;
77        self.ocs
78            .get(path, Query::new().format(OcsFormat::Json))
79            .await
80    }
81
82    /// Creates a new conversation room.
83    ///
84    /// Sends `POST /ocs/v2.php/apps/spreed/api/v4/room`.
85    pub async fn create_room(
86        &self,
87        room_type: RoomType,
88        invite: Option<&str>,
89        name: Option<&str>,
90    ) -> Result<Room, Error> {
91        tracing::info!(?room_type, ?name, "Creating Talk room");
92        let path = ApiPath::ocs(ROOM_API_PATH)?;
93        let request = CreateRoomRequest {
94            room_type,
95            invite,
96            room_name: name,
97        };
98        self.ocs
99            .post_with_query(path, Query::new().format(OcsFormat::Json), &request)
100            .await
101    }
102
103    /// Deletes a conversation room by its token.
104    ///
105    /// Sends `DELETE /ocs/v2.php/apps/spreed/api/v4/room/{token}`.
106    pub async fn delete_room(&self, token: &str) -> Result<(), Error> {
107        tracing::info!(token, "Deleting Talk room");
108        let path = ApiPath::ocs(format!("{ROOM_API_PATH}/{token}"))?;
109        self.ocs
110            .delete_with_query::<()>(path, Query::new().format(OcsFormat::Json))
111            .await
112    }
113
114    /// Lists chat messages in a conversation.
115    ///
116    /// Sends `GET /ocs/v2.php/apps/spreed/api/v1/chat/{token}` with `lookIntoFuture=0`
117    /// and optional `limit`.
118    pub async fn list_messages(
119        &self,
120        token: &str,
121        limit: Option<u32>,
122    ) -> Result<Vec<ChatMessage>, Error> {
123        let path = ApiPath::ocs(format!("{CHAT_API_PATH}/{token}"))?;
124        let mut query = Query::new()
125            .format(OcsFormat::Json)
126            .param("lookIntoFuture", "0");
127        if let Some(limit) = limit {
128            query = query.param("limit", limit.to_string());
129        }
130        self.ocs.get(path, query).await
131    }
132
133    /// Sends a new chat message to a conversation.
134    ///
135    /// Sends `POST /ocs/v2.php/apps/spreed/api/v1/chat/{token}` with `{"message": "..."}`.
136    pub async fn send_message(&self, token: &str, message: &str) -> Result<ChatMessage, Error> {
137        let path = ApiPath::ocs(format!("{CHAT_API_PATH}/{token}"))?;
138        let request = SendMessageRequest { message };
139        self.ocs
140            .post_with_query(path, Query::new().format(OcsFormat::Json), &request)
141            .await
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use std::future::Future;
148    use std::sync::{Arc, Mutex};
149    use std::task::{Context, Poll, Waker};
150
151    use nextcloud_ocs::OcsClient;
152    use nextcloud_sdk_core::{
153        AuthProvider, Error, Headers, HttpMethod, Request, Response, StatusCode, Transport,
154    };
155
156    use super::TalkClient;
157    use crate::room_type::RoomType;
158
159    fn block_on<F: Future>(future: F) -> F::Output {
160        let mut future = std::pin::pin!(future);
161        let mut context = Context::from_waker(Waker::noop());
162        loop {
163            match future.as_mut().poll(&mut context) {
164                Poll::Ready(value) => return value,
165                Poll::Pending => std::thread::yield_now(),
166            }
167        }
168    }
169
170    #[derive(Clone, Debug)]
171    struct FakeTransport {
172        response: Response,
173        seen: Arc<Mutex<Option<Request>>>,
174    }
175
176    impl FakeTransport {
177        fn new(status: u16, body: &str) -> Self {
178            Self {
179                response: Response::new(StatusCode::new(status), Headers::new(), body.as_bytes()),
180                seen: Arc::new(Mutex::new(None)),
181            }
182        }
183
184        fn request(&self) -> Option<Request> {
185            self.seen.lock().ok().and_then(|s| s.clone())
186        }
187    }
188
189    impl Transport for FakeTransport {
190        async fn execute(&self, request: Request) -> Result<Response, Error> {
191            if let Ok(mut seen) = self.seen.lock() {
192                *seen = Some(request);
193            }
194            Ok(self.response.clone())
195        }
196    }
197
198    #[derive(Clone, Debug)]
199    struct FakeAuth;
200
201    impl AuthProvider for FakeAuth {
202        fn authenticate(&self, headers: &mut Headers) {
203            headers.set("Authorization", "Bearer test-talk-token");
204        }
205    }
206
207    const LIST_ROOMS_RESPONSE: &str = r#"{
208        "ocs": {
209            "meta": { "status": "ok", "statuscode": 200, "message": "OK" },
210            "data": [
211                {
212                    "token": "tok1",
213                    "name": "General",
214                    "displayName": "General",
215                    "type": 2,
216                    "description": "General discussions",
217                    "hasPassword": false,
218                    "participantCount": 8
219                },
220                {
221                    "token": "tok2",
222                    "name": "Direct Alice",
223                    "type": 1,
224                    "hasPassword": false
225                }
226            ]
227        }
228    }"#;
229
230    #[test]
231    fn test_list_rooms() {
232        let transport = FakeTransport::new(200, LIST_ROOMS_RESPONSE);
233        let ocs = OcsClient::new(transport.clone(), FakeAuth);
234        let client = TalkClient::new(&ocs);
235
236        let rooms = block_on(client.list_rooms()).expect("list rooms succeeds");
237        assert_eq!(rooms.len(), 2);
238        assert_eq!(rooms[0].token, "tok1");
239        assert_eq!(rooms[0].name, "General");
240        assert_eq!(rooms[0].room_type, RoomType::Group);
241        assert_eq!(rooms[0].description.as_deref(), Some("General discussions"));
242        assert!(!rooms[0].has_password);
243        assert_eq!(rooms[0].participant_count, Some(8));
244
245        assert_eq!(rooms[1].token, "tok2");
246        assert_eq!(rooms[1].room_type, RoomType::OneToOne);
247
248        let req = transport.request().expect("request was sent");
249        assert_eq!(req.method(), HttpMethod::Get);
250        assert_eq!(
251            req.path().as_str(),
252            "/ocs/v2.php/apps/spreed/api/v4/room?format=json"
253        );
254    }
255
256    const GET_ROOM_RESPONSE: &str = r#"{
257        "ocs": {
258            "meta": { "status": "ok", "statuscode": 200, "message": "OK" },
259            "data": {
260                "token": "abcde",
261                "roomName": "Dev Team",
262                "roomType": 2,
263                "description": "Engineers only",
264                "hasPassword": true,
265                "participantCount": 4
266            }
267        }
268    }"#;
269
270    #[test]
271    fn test_get_room() {
272        let transport = FakeTransport::new(200, GET_ROOM_RESPONSE);
273        let ocs = OcsClient::new(transport.clone(), FakeAuth);
274        let client = TalkClient::new(&ocs);
275
276        let room = block_on(client.get_room("abcde")).expect("get room succeeds");
277        assert_eq!(room.token, "abcde");
278        assert_eq!(room.name, "Dev Team");
279        assert_eq!(room.room_type, RoomType::Group);
280        assert_eq!(room.description.as_deref(), Some("Engineers only"));
281        assert!(room.has_password);
282        assert_eq!(room.participant_count, Some(4));
283
284        let req = transport.request().expect("request was sent");
285        assert_eq!(req.method(), HttpMethod::Get);
286        assert_eq!(
287            req.path().as_str(),
288            "/ocs/v2.php/apps/spreed/api/v4/room/abcde?format=json"
289        );
290    }
291
292    const CREATE_ROOM_RESPONSE: &str = r#"{
293        "ocs": {
294            "meta": { "status": "ok", "statuscode": 200, "message": "OK" },
295            "data": {
296                "token": "newtok123",
297                "name": "New Project",
298                "type": 2,
299                "description": "",
300                "hasPassword": false
301            }
302        }
303    }"#;
304
305    #[test]
306    fn test_create_room() {
307        let transport = FakeTransport::new(200, CREATE_ROOM_RESPONSE);
308        let ocs = OcsClient::new(transport.clone(), FakeAuth);
309        let client = TalkClient::new(&ocs);
310
311        let room = block_on(client.create_room(RoomType::Group, None, Some("New Project")))
312            .expect("create room succeeds");
313        assert_eq!(room.token, "newtok123");
314        assert_eq!(room.name, "New Project");
315        assert_eq!(room.room_type, RoomType::Group);
316
317        let req = transport.request().expect("request was sent");
318        assert_eq!(req.method(), HttpMethod::Post);
319        assert_eq!(
320            req.path().as_str(),
321            "/ocs/v2.php/apps/spreed/api/v4/room?format=json"
322        );
323
324        let body_str = String::from_utf8(req.body().expect("request body").to_vec()).unwrap();
325        assert!(body_str.contains(r#""roomType":2"#));
326        assert!(body_str.contains(r#""roomName":"New Project""#));
327        assert!(!body_str.contains("invite"));
328    }
329
330    const EMPTY_OCS_RESPONSE: &str = r#"{
331        "ocs": {
332            "meta": { "status": "ok", "statuscode": 200, "message": "OK" },
333            "data": []
334        }
335    }"#;
336
337    #[test]
338    fn test_delete_room() {
339        let transport = FakeTransport::new(200, EMPTY_OCS_RESPONSE);
340        let ocs = OcsClient::new(transport.clone(), FakeAuth);
341        let client = TalkClient::new(&ocs);
342
343        block_on(client.delete_room("deadbeef")).expect("delete room succeeds");
344
345        let req = transport.request().expect("request was sent");
346        assert_eq!(req.method(), HttpMethod::Delete);
347        assert_eq!(
348            req.path().as_str(),
349            "/ocs/v2.php/apps/spreed/api/v4/room/deadbeef?format=json"
350        );
351    }
352
353    const LIST_MESSAGES_RESPONSE: &str = r#"{
354        "ocs": {
355            "meta": { "status": "ok", "statuscode": 200, "message": "OK" },
356            "data": [
357                {
358                    "id": 1,
359                    "token": "tok1",
360                    "actorType": "users",
361                    "actorId": "bob",
362                    "actorDisplayName": "Bob",
363                    "timestamp": 1718000000,
364                    "message": "Hi all",
365                    "systemMessage": ""
366                },
367                {
368                    "id": 2,
369                    "token": "tok1",
370                    "actorType": "users",
371                    "actorId": "system",
372                    "actorDisplayName": "System",
373                    "timestamp": 1718000100,
374                    "message": "User left",
375                    "systemMessage": "user_left"
376                }
377            ]
378        }
379    }"#;
380
381    #[test]
382    fn test_list_messages() {
383        let transport = FakeTransport::new(200, LIST_MESSAGES_RESPONSE);
384        let ocs = OcsClient::new(transport.clone(), FakeAuth);
385        let client = TalkClient::new(&ocs);
386
387        let messages =
388            block_on(client.list_messages("tok1", Some(50))).expect("list messages succeeds");
389        assert_eq!(messages.len(), 2);
390        assert_eq!(messages[0].id, 1);
391        assert_eq!(messages[0].actor_id, "bob");
392        assert_eq!(messages[0].message, "Hi all");
393        assert!(!messages[0].is_system_message());
394
395        assert_eq!(messages[1].id, 2);
396        assert_eq!(messages[1].system_message, "user_left");
397        assert!(messages[1].is_system_message());
398
399        let req = transport.request().expect("request was sent");
400        assert_eq!(req.method(), HttpMethod::Get);
401        assert_eq!(
402            req.path().as_str(),
403            "/ocs/v2.php/apps/spreed/api/v1/chat/tok1?format=json&lookIntoFuture=0&limit=50"
404        );
405    }
406
407    const SEND_MESSAGE_RESPONSE: &str = r#"{
408        "ocs": {
409            "meta": { "status": "ok", "statuscode": 200, "message": "OK" },
410            "data": {
411                "id": 42,
412                "token": "tok1",
413                "actorType": "users",
414                "actorId": "alice",
415                "actorDisplayName": "Alice Cooper",
416                "timestamp": 1718000200,
417                "message": "Hello world!",
418                "systemMessage": ""
419            }
420        }
421    }"#;
422
423    #[test]
424    fn test_send_message() {
425        let transport = FakeTransport::new(200, SEND_MESSAGE_RESPONSE);
426        let ocs = OcsClient::new(transport.clone(), FakeAuth);
427        let client = TalkClient::new(&ocs);
428
429        let msg =
430            block_on(client.send_message("tok1", "Hello world!")).expect("send message succeeds");
431        assert_eq!(msg.id, 42);
432        assert_eq!(msg.token, "tok1");
433        assert_eq!(msg.actor_id, "alice");
434        assert_eq!(msg.actor_display_name, "Alice Cooper");
435        assert_eq!(msg.message, "Hello world!");
436
437        let req = transport.request().expect("request was sent");
438        assert_eq!(req.method(), HttpMethod::Post);
439        assert_eq!(
440            req.path().as_str(),
441            "/ocs/v2.php/apps/spreed/api/v1/chat/tok1?format=json"
442        );
443
444        let body_str = String::from_utf8(req.body().expect("request body").to_vec()).unwrap();
445        assert_eq!(body_str, r#"{"message":"Hello world!"}"#);
446    }
447}