1use 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
14pub const ROOM_API_PATH: &str = "apps/spreed/api/v4/room";
16
17pub const CHAT_API_PATH: &str = "apps/spreed/api/v1/chat";
19
20#[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 pub const fn new(ocs: &'a OcsClient<T, A>) -> Self {
52 Self { ocs }
53 }
54
55 pub const fn ocs(&self) -> &'a OcsClient<T, A> {
57 self.ocs
58 }
59
60 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 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 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 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 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 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}