1use super::traits::{Channel, ChannelMessage, SendMessage};
2use async_trait::async_trait;
3use hmac::{Hmac, Mac};
4use sha2::Sha256;
5use uuid::Uuid;
6
7pub struct NextcloudTalkChannel {
12 base_url: String,
13 app_token: String,
14 bot_name: String,
15 allowed_users: Vec<String>,
16 client: reqwest::Client,
17}
18
19impl NextcloudTalkChannel {
20 pub fn new(
21 base_url: String,
22 app_token: String,
23 bot_name: String,
24 allowed_users: Vec<String>,
25 ) -> Self {
26 Self::new_with_proxy(base_url, app_token, bot_name, allowed_users, None)
27 }
28
29 pub fn new_with_proxy(
30 base_url: String,
31 app_token: String,
32 bot_name: String,
33 allowed_users: Vec<String>,
34 proxy_url: Option<String>,
35 ) -> Self {
36 Self {
37 base_url: base_url.trim_end_matches('/').to_string(),
38 app_token,
39 bot_name: bot_name.to_ascii_lowercase(),
40 allowed_users,
41 client: crate::config::build_channel_proxy_client(
42 "channel.nextcloud_talk",
43 proxy_url.as_deref(),
44 ),
45 }
46 }
47
48 fn is_user_allowed(&self, actor_id: &str) -> bool {
49 self.allowed_users.iter().any(|u| u == "*" || u == actor_id)
50 }
51
52 fn is_bot_name(&self, name: &str) -> bool {
56 let name = name.to_ascii_lowercase();
57 (!self.bot_name.is_empty() && name == self.bot_name) || name == "construct"
59 }
60
61 fn now_unix_secs() -> u64 {
62 std::time::SystemTime::now()
63 .duration_since(std::time::UNIX_EPOCH)
64 .unwrap_or_default()
65 .as_secs()
66 }
67
68 fn parse_timestamp_secs(value: Option<&serde_json::Value>) -> u64 {
69 let raw = match value {
70 Some(serde_json::Value::Number(num)) => num.as_u64(),
71 Some(serde_json::Value::String(s)) => s.trim().parse::<u64>().ok(),
72 _ => None,
73 }
74 .unwrap_or_else(Self::now_unix_secs);
75
76 if raw > 1_000_000_000_000 {
78 raw / 1000
79 } else {
80 raw
81 }
82 }
83
84 fn value_to_string(value: Option<&serde_json::Value>) -> Option<String> {
85 match value {
86 Some(serde_json::Value::String(s)) => Some(s.clone()),
87 Some(serde_json::Value::Number(n)) => Some(n.to_string()),
88 _ => None,
89 }
90 }
91
92 pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
116 let messages = Vec::new();
117
118 let event_type = match payload.get("type").and_then(|v| v.as_str()) {
119 Some(t) => t,
120 None => return messages,
121 };
122
123 if event_type.eq_ignore_ascii_case("create") {
125 return self.parse_as2_payload(payload);
126 }
127
128 if !event_type.eq_ignore_ascii_case("message") {
130 tracing::debug!("Nextcloud Talk: skipping non-message event: {event_type}");
131 return messages;
132 }
133
134 self.parse_message_payload(payload)
135 }
136
137 fn parse_as2_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
139 let mut messages = Vec::new();
140
141 let obj = match payload.get("object") {
142 Some(o) => o,
143 None => return messages,
144 };
145
146 let object_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
148 if !object_type.eq_ignore_ascii_case("note") {
149 tracing::debug!("Nextcloud Talk: skipping AS2 Create with object.type={object_type}");
150 return messages;
151 }
152
153 let room_token = payload
155 .get("target")
156 .and_then(|t| t.get("id"))
157 .and_then(|v| v.as_str())
158 .map(str::trim)
159 .filter(|t| !t.is_empty());
160
161 let Some(room_token) = room_token else {
162 tracing::warn!("Nextcloud Talk: missing target.id (room token) in AS2 payload");
163 return messages;
164 };
165
166 let actor = payload.get("actor").cloned().unwrap_or_default();
168 let actor_type = actor.get("type").and_then(|v| v.as_str()).unwrap_or("");
169 if actor_type.eq_ignore_ascii_case("application") {
170 tracing::debug!(
171 "Nextcloud Talk: skipping bot-originated AS2 message (type=Application)"
172 );
173 return messages;
174 }
175
176 let actor_id = actor
178 .get("id")
179 .and_then(|v| v.as_str())
180 .map(|id| {
181 id.trim_start_matches("users/")
182 .trim_start_matches("bots/")
183 .trim()
184 })
185 .filter(|id| !id.is_empty());
186
187 let Some(actor_id) = actor_id else {
188 tracing::warn!("Nextcloud Talk: missing actor.id in AS2 payload");
189 return messages;
190 };
191
192 let raw_actor_id = actor.get("id").and_then(|v| v.as_str()).unwrap_or("");
195 if raw_actor_id.starts_with("bots/") {
196 tracing::debug!(
197 "Nextcloud Talk: skipping bot-originated AS2 message (id prefix=bots/)"
198 );
199 return messages;
200 }
201 let actor_name = actor
202 .get("name")
203 .and_then(|v| v.as_str())
204 .unwrap_or("")
205 .to_ascii_lowercase();
206 if self.is_bot_name(&actor_name) {
207 tracing::debug!(
208 "Nextcloud Talk: skipping bot-originated AS2 message (name={actor_name})"
209 );
210 return messages;
211 }
212
213 if !self.is_user_allowed(actor_id) {
214 tracing::warn!(
215 "Nextcloud Talk: ignoring message from unauthorized actor: {actor_id}. \
216 Add to channels.nextcloud_talk.allowed_users in config.toml, \
217 or run `construct onboard --channels-only` to configure interactively."
218 );
219 return messages;
220 }
221
222 let content = obj
225 .get("content")
226 .and_then(|v| v.as_str())
227 .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
228 .and_then(|v| {
229 v.get("message")
230 .and_then(|m| m.as_str())
231 .map(str::trim)
232 .map(str::to_string)
233 })
234 .filter(|s| !s.is_empty());
235
236 let Some(content) = content else {
237 tracing::debug!("Nextcloud Talk: empty or unparseable AS2 message content");
238 return messages;
239 };
240
241 let message_id =
242 Self::value_to_string(obj.get("id")).unwrap_or_else(|| Uuid::new_v4().to_string());
243
244 messages.push(ChannelMessage {
245 id: message_id,
246 reply_target: room_token.to_string(),
247 sender: actor_id.to_string(),
248 content,
249 channel: "nextcloud_talk".to_string(),
250 timestamp: Self::now_unix_secs(),
251 thread_ts: None,
252 interruption_scope_id: None,
253 attachments: vec![],
254 });
255
256 messages
257 }
258
259 fn parse_message_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
261 let mut messages = Vec::new();
262
263 let Some(message_obj) = payload.get("message") else {
264 return messages;
265 };
266
267 let room_token = payload
268 .get("object")
269 .and_then(|obj| obj.get("token"))
270 .and_then(|v| v.as_str())
271 .or_else(|| message_obj.get("token").and_then(|v| v.as_str()))
272 .map(str::trim)
273 .filter(|token| !token.is_empty());
274
275 let Some(room_token) = room_token else {
276 tracing::warn!("Nextcloud Talk: missing room token in webhook payload");
277 return messages;
278 };
279
280 let actor_type = message_obj
281 .get("actorType")
282 .and_then(|v| v.as_str())
283 .or_else(|| payload.get("actorType").and_then(|v| v.as_str()))
284 .unwrap_or("");
285
286 if actor_type.eq_ignore_ascii_case("bots") || actor_type.eq_ignore_ascii_case("application")
289 {
290 tracing::debug!(
291 "Nextcloud Talk: skipping bot-originated message (actorType={actor_type})"
292 );
293 return messages;
294 }
295
296 let actor_id = message_obj
297 .get("actorId")
298 .and_then(|v| v.as_str())
299 .or_else(|| payload.get("actorId").and_then(|v| v.as_str()))
300 .map(str::trim)
301 .filter(|id| !id.is_empty());
302
303 let Some(actor_id) = actor_id else {
304 tracing::warn!("Nextcloud Talk: missing actorId in webhook payload");
305 return messages;
306 };
307
308 if self.is_bot_name(actor_id) {
310 tracing::debug!("Nextcloud Talk: skipping bot-originated message (actorId={actor_id})");
311 return messages;
312 }
313
314 if !self.is_user_allowed(actor_id) {
315 tracing::warn!(
316 "Nextcloud Talk: ignoring message from unauthorized actor: {actor_id}. \
317 Add to channels.nextcloud_talk.allowed_users in config.toml, \
318 or run `construct onboard --channels-only` to configure interactively."
319 );
320 return messages;
321 }
322
323 let message_type = message_obj
324 .get("messageType")
325 .and_then(|v| v.as_str())
326 .unwrap_or("comment");
327 if !message_type.eq_ignore_ascii_case("comment") {
328 tracing::debug!("Nextcloud Talk: skipping non-comment messageType: {message_type}");
329 return messages;
330 }
331
332 let has_system_message = message_obj
334 .get("systemMessage")
335 .and_then(|v| v.as_str())
336 .map(str::trim)
337 .is_some_and(|value| !value.is_empty());
338 if has_system_message {
339 tracing::debug!("Nextcloud Talk: skipping system message event");
340 return messages;
341 }
342
343 let content = message_obj
344 .get("message")
345 .and_then(|v| v.as_str())
346 .map(str::trim)
347 .filter(|content| !content.is_empty());
348
349 let Some(content) = content else {
350 return messages;
351 };
352
353 let message_id = Self::value_to_string(message_obj.get("id"))
354 .unwrap_or_else(|| Uuid::new_v4().to_string());
355 let timestamp = Self::parse_timestamp_secs(message_obj.get("timestamp"));
356
357 messages.push(ChannelMessage {
358 id: message_id,
359 reply_target: room_token.to_string(),
360 sender: actor_id.to_string(),
361 content: content.to_string(),
362 channel: "nextcloud_talk".to_string(),
363 timestamp,
364 thread_ts: None,
365 interruption_scope_id: None,
366 attachments: vec![],
367 });
368
369 messages
370 }
371
372 async fn send_to_room(&self, room_token: &str, content: &str) -> anyhow::Result<()> {
373 let encoded_room = urlencoding::encode(room_token);
374 let url = format!(
375 "{}/ocs/v2.php/apps/spreed/api/v1/chat/{}?format=json",
376 self.base_url, encoded_room
377 );
378
379 let response = self
380 .client
381 .post(&url)
382 .bearer_auth(&self.app_token)
383 .header("OCS-APIRequest", "true")
384 .header("Accept", "application/json")
385 .json(&serde_json::json!({ "message": content }))
386 .send()
387 .await?;
388
389 if response.status().is_success() {
390 return Ok(());
391 }
392
393 let status = response.status();
394 let body = response.text().await.unwrap_or_default();
395 tracing::error!("Nextcloud Talk send failed: {status} — {body}");
396 anyhow::bail!("Nextcloud Talk API error: {status}");
397 }
398}
399
400#[async_trait]
401impl Channel for NextcloudTalkChannel {
402 fn name(&self) -> &str {
403 "nextcloud_talk"
404 }
405
406 async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
407 self.send_to_room(&message.recipient, &message.content)
408 .await
409 }
410
411 async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
412 tracing::info!(
413 "Nextcloud Talk channel active (webhook mode). \
414 Configure Nextcloud Talk bot webhook to POST to your gateway's /nextcloud-talk endpoint."
415 );
416
417 loop {
419 tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
420 }
421 }
422
423 async fn health_check(&self) -> bool {
424 let url = format!("{}/status.php", self.base_url);
425
426 self.client
427 .get(&url)
428 .send()
429 .await
430 .map(|r| r.status().is_success())
431 .unwrap_or(false)
432 }
433}
434
435pub fn verify_nextcloud_talk_signature(
440 secret: &str,
441 random: &str,
442 body: &str,
443 signature: &str,
444) -> bool {
445 let random = random.trim();
446 if random.is_empty() {
447 tracing::warn!("Nextcloud Talk: missing X-Nextcloud-Talk-Random header");
448 return false;
449 }
450
451 let signature_hex = signature
452 .trim()
453 .strip_prefix("sha256=")
454 .unwrap_or(signature)
455 .trim();
456
457 let Ok(provided) = hex::decode(signature_hex) else {
458 tracing::warn!("Nextcloud Talk: invalid signature format");
459 return false;
460 };
461
462 let payload = format!("{random}{body}");
463 let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
464 return false;
465 };
466 mac.update(payload.as_bytes());
467
468 mac.verify_slice(&provided).is_ok()
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 fn make_channel() -> NextcloudTalkChannel {
476 NextcloudTalkChannel::new(
477 "https://cloud.example.com".into(),
478 "app-token".into(),
479 "construct".into(),
480 vec!["user_a".into()],
481 )
482 }
483
484 #[test]
485 fn nextcloud_talk_channel_name() {
486 let channel = make_channel();
487 assert_eq!(channel.name(), "nextcloud_talk");
488 }
489
490 #[test]
491 fn nextcloud_talk_user_allowlist_exact_and_wildcard() {
492 let channel = make_channel();
493 assert!(channel.is_user_allowed("user_a"));
494 assert!(!channel.is_user_allowed("user_b"));
495
496 let wildcard = NextcloudTalkChannel::new(
497 "https://cloud.example.com".into(),
498 "app-token".into(),
499 "construct".into(),
500 vec!["*".into()],
501 );
502 assert!(wildcard.is_user_allowed("any_user"));
503 }
504
505 #[test]
506 fn nextcloud_talk_parse_valid_message_payload() {
507 let channel = make_channel();
508 let payload = serde_json::json!({
509 "type": "message",
510 "object": {
511 "id": "42",
512 "token": "room-token-123",
513 "name": "Team Room",
514 "type": "room"
515 },
516 "message": {
517 "id": 77,
518 "token": "room-token-123",
519 "actorType": "users",
520 "actorId": "user_a",
521 "actorDisplayName": "User A",
522 "timestamp": 1_735_701_200,
523 "messageType": "comment",
524 "systemMessage": "",
525 "message": "Hello from Nextcloud"
526 }
527 });
528
529 let messages = channel.parse_webhook_payload(&payload);
530 assert_eq!(messages.len(), 1);
531 assert_eq!(messages[0].id, "77");
532 assert_eq!(messages[0].reply_target, "room-token-123");
533 assert_eq!(messages[0].sender, "user_a");
534 assert_eq!(messages[0].content, "Hello from Nextcloud");
535 assert_eq!(messages[0].channel, "nextcloud_talk");
536 assert_eq!(messages[0].timestamp, 1_735_701_200);
537 }
538
539 #[test]
540 fn nextcloud_talk_parse_as2_create_payload() {
541 let channel = NextcloudTalkChannel::new(
542 "https://cloud.example.com".into(),
543 "app-token".into(),
544 "construct".into(),
545 vec!["*".into()],
546 );
547 let payload = serde_json::json!({
549 "type": "Create",
550 "actor": {
551 "type": "Person",
552 "id": "users/user_a",
553 "name": "User A",
554 "talkParticipantType": "1"
555 },
556 "object": {
557 "type": "Note",
558 "id": "177",
559 "name": "message",
560 "content": "{\"message\":\"hallo, bist du da?\",\"parameters\":[]}",
561 "mediaType": "text/markdown"
562 },
563 "target": {
564 "type": "Collection",
565 "id": "room-token-123",
566 "name": "HOME"
567 }
568 });
569
570 let messages = channel.parse_webhook_payload(&payload);
571 assert_eq!(messages.len(), 1);
572 assert_eq!(messages[0].reply_target, "room-token-123");
573 assert_eq!(messages[0].sender, "user_a");
574 assert_eq!(messages[0].content, "hallo, bist du da?");
575 assert_eq!(messages[0].channel, "nextcloud_talk");
576 }
577
578 #[test]
579 fn nextcloud_talk_parse_as2_skips_bot_originated() {
580 let channel = NextcloudTalkChannel::new(
581 "https://cloud.example.com".into(),
582 "app-token".into(),
583 "construct".into(),
584 vec!["*".into()],
585 );
586 let payload = serde_json::json!({
587 "type": "Create",
588 "actor": {
589 "type": "Application",
590 "id": "bots/construct",
591 "name": "construct"
592 },
593 "object": {
594 "type": "Note",
595 "id": "178",
596 "content": "{\"message\":\"I am the bot\",\"parameters\":[]}",
597 "mediaType": "text/markdown"
598 },
599 "target": {
600 "type": "Collection",
601 "id": "room-token-123",
602 "name": "HOME"
603 }
604 });
605
606 let messages = channel.parse_webhook_payload(&payload);
607 assert!(messages.is_empty());
608 }
609
610 #[test]
611 fn nextcloud_talk_parse_as2_skips_bot_by_name() {
612 let channel = NextcloudTalkChannel::new(
615 "https://cloud.example.com".into(),
616 "app-token".into(),
617 "construct".into(),
618 vec!["*".into()],
619 );
620 let payload = serde_json::json!({
621 "type": "Create",
622 "actor": {
623 "type": "Person", "id": "users/construct",
625 "name": "construct"
626 },
627 "object": {
628 "type": "Note",
629 "id": "999",
630 "content": "{\"message\":\"I am the bot\",\"parameters\":[]}",
631 "mediaType": "text/markdown"
632 },
633 "target": {
634 "type": "Collection",
635 "id": "room-token-123",
636 "name": "HOME"
637 }
638 });
639
640 let messages = channel.parse_webhook_payload(&payload);
641 assert!(
642 messages.is_empty(),
643 "bot message should be filtered even if actor.type is wrong"
644 );
645 }
646
647 #[test]
648 fn nextcloud_talk_parse_message_skips_application_actor_type() {
649 let channel = NextcloudTalkChannel::new(
651 "https://cloud.example.com".into(),
652 "app-token".into(),
653 "construct".into(),
654 vec!["*".into()],
655 );
656 let payload = serde_json::json!({
657 "type": "message",
658 "object": {"token": "room-token-123"},
659 "message": {
660 "actorType": "application",
661 "actorId": "construct",
662 "message": "Self message"
663 }
664 });
665
666 let messages = channel.parse_webhook_payload(&payload);
667 assert!(
668 messages.is_empty(),
669 "application actorType must be filtered in legacy format"
670 );
671 }
672
673 #[test]
674 fn nextcloud_talk_parse_as2_skips_non_note_objects() {
675 let channel = NextcloudTalkChannel::new(
676 "https://cloud.example.com".into(),
677 "app-token".into(),
678 "construct".into(),
679 vec!["*".into()],
680 );
681 let payload = serde_json::json!({
682 "type": "Create",
683 "actor": { "type": "Person", "id": "users/user_a" },
684 "object": { "type": "Reaction", "id": "5" },
685 "target": { "type": "Collection", "id": "room-token-123" }
686 });
687
688 let messages = channel.parse_webhook_payload(&payload);
689 assert!(messages.is_empty());
690 }
691
692 #[test]
693 fn nextcloud_talk_parse_skips_non_message_events() {
694 let channel = make_channel();
695 let payload = serde_json::json!({
696 "type": "room",
697 "object": {"token": "room-token-123"},
698 "message": {
699 "actorType": "users",
700 "actorId": "user_a",
701 "message": "Hello"
702 }
703 });
704
705 let messages = channel.parse_webhook_payload(&payload);
706 assert!(messages.is_empty());
707 }
708
709 #[test]
710 fn nextcloud_talk_parse_skips_bot_messages() {
711 let channel = NextcloudTalkChannel::new(
712 "https://cloud.example.com".into(),
713 "app-token".into(),
714 "construct".into(),
715 vec!["*".into()],
716 );
717 let payload = serde_json::json!({
718 "type": "message",
719 "object": {"token": "room-token-123"},
720 "message": {
721 "actorType": "bots",
722 "actorId": "bot_1",
723 "message": "Self message"
724 }
725 });
726
727 let messages = channel.parse_webhook_payload(&payload);
728 assert!(messages.is_empty());
729 }
730
731 #[test]
732 fn nextcloud_talk_parse_skips_unauthorized_sender() {
733 let channel = make_channel();
734 let payload = serde_json::json!({
735 "type": "message",
736 "object": {"token": "room-token-123"},
737 "message": {
738 "actorType": "users",
739 "actorId": "user_b",
740 "message": "Unauthorized"
741 }
742 });
743
744 let messages = channel.parse_webhook_payload(&payload);
745 assert!(messages.is_empty());
746 }
747
748 #[test]
749 fn nextcloud_talk_parse_skips_system_message() {
750 let channel = NextcloudTalkChannel::new(
751 "https://cloud.example.com".into(),
752 "app-token".into(),
753 "construct".into(),
754 vec!["*".into()],
755 );
756 let payload = serde_json::json!({
757 "type": "message",
758 "object": {"token": "room-token-123"},
759 "message": {
760 "actorType": "users",
761 "actorId": "user_a",
762 "messageType": "comment",
763 "systemMessage": "joined",
764 "message": ""
765 }
766 });
767
768 let messages = channel.parse_webhook_payload(&payload);
769 assert!(messages.is_empty());
770 }
771
772 #[test]
773 fn nextcloud_talk_parse_timestamp_millis_to_seconds() {
774 let channel = NextcloudTalkChannel::new(
775 "https://cloud.example.com".into(),
776 "app-token".into(),
777 "construct".into(),
778 vec!["*".into()],
779 );
780 let payload = serde_json::json!({
781 "type": "message",
782 "object": {"token": "room-token-123"},
783 "message": {
784 "actorType": "users",
785 "actorId": "user_a",
786 "timestamp": 1_735_701_200_123_u64,
787 "message": "hello"
788 }
789 });
790
791 let messages = channel.parse_webhook_payload(&payload);
792 assert_eq!(messages.len(), 1);
793 assert_eq!(messages[0].timestamp, 1_735_701_200);
794 }
795
796 const TEST_WEBHOOK_SECRET: &str = "nextcloud_test_webhook_secret";
797
798 #[test]
799 fn nextcloud_talk_signature_verification_valid() {
800 let secret = TEST_WEBHOOK_SECRET;
801 let random = "random-seed";
802 let body = r#"{"type":"message"}"#;
803
804 let payload = format!("{random}{body}");
805 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
806 mac.update(payload.as_bytes());
807 let signature = hex::encode(mac.finalize().into_bytes());
808
809 assert!(verify_nextcloud_talk_signature(
810 secret, random, body, &signature
811 ));
812 }
813
814 #[test]
815 fn nextcloud_talk_signature_verification_invalid() {
816 assert!(!verify_nextcloud_talk_signature(
817 TEST_WEBHOOK_SECRET,
818 "random-seed",
819 r#"{"type":"message"}"#,
820 "deadbeef"
821 ));
822 }
823
824 #[test]
825 fn nextcloud_talk_signature_verification_accepts_sha256_prefix() {
826 let secret = TEST_WEBHOOK_SECRET;
827 let random = "random-seed";
828 let body = r#"{"type":"message"}"#;
829
830 let payload = format!("{random}{body}");
831 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
832 mac.update(payload.as_bytes());
833 let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
834
835 assert!(verify_nextcloud_talk_signature(
836 secret, random, body, &signature
837 ));
838 }
839}