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 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 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 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 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 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 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}