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