Skip to main content

construct/channels/
nextcloud_talk.rs

1use super::traits::{Channel, ChannelMessage, SendMessage};
2use async_trait::async_trait;
3use hmac::{Hmac, Mac};
4use sha2::Sha256;
5use uuid::Uuid;
6
7/// Nextcloud Talk channel in webhook mode.
8///
9/// Incoming messages are received by the gateway endpoint `/nextcloud-talk`.
10/// Outbound replies are sent through Nextcloud Talk OCS API.
11pub 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    /// Returns true if the given name/id belongs to this bot itself.
53    ///
54    /// Prevents feedback loops where Construct reacts to its own messages.
55    fn is_bot_name(&self, name: &str) -> bool {
56        let name = name.to_ascii_lowercase();
57        // Match the configured bot name, or the known bot name "construct".
58        (!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        // Some payloads use milliseconds.
77        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    /// Parse a Nextcloud Talk webhook payload into channel messages.
93    ///
94    /// Two payload formats are supported:
95    ///
96    /// **Format A — legacy/custom** (`type: "message"`):
97    /// ```json
98    /// {
99    ///   "type": "message",
100    ///   "object": { "token": "<room>" },
101    ///   "message": { "actorId": "...", "message": "...", ... }
102    /// }
103    /// ```
104    ///
105    /// **Format B — Activity Streams 2.0** (`type: "Create"`):
106    /// This is the format actually sent by Nextcloud Talk bot webhooks.
107    /// ```json
108    /// {
109    ///   "type": "Create",
110    ///   "actor": { "type": "Person", "id": "users/alice", "name": "Alice" },
111    ///   "object": { "type": "Note", "id": "177", "content": "{\"message\":\"hi\",\"parameters\":[]}", "mediaType": "text/markdown" },
112    ///   "target": { "type": "Collection", "id": "<room_token>", "name": "Room Name" }
113    /// }
114    /// ```
115    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        // Activity Streams 2.0 format sent by Nextcloud Talk bot webhooks.
124        if event_type.eq_ignore_ascii_case("create") {
125            return self.parse_as2_payload(payload);
126        }
127
128        // Legacy/custom format.
129        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    /// Parse Activity Streams 2.0 `Create` payload (real Nextcloud Talk bot webhook format).
138    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        // Only handle Note objects (= chat messages). Ignore reactions, etc.
147        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        // Room token is in target.id.
154        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        // Actor — skip bot-originated messages to prevent feedback loops.
167        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        // actor.id is "users/<id>" or "bots/<id>" — strip the prefix.
177        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        // Also skip by actor.id prefix or known bot names — Nextcloud does not always
193        // set actor.type="Application" reliably for bot-sent messages.
194        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        // Message text is JSON-encoded inside object.content.
223        // e.g. content = "{\"message\":\"hello\",\"parameters\":[]}"
224        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    /// Parse legacy `type: "message"` payload format.
260    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        // Ignore bot-originated messages to prevent feedback loops.
287        // Nextcloud Talk uses "bots" or "application" depending on version/context.
288        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        // Also skip by known bot names in case actorType is not set reliably.
309        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        // Ignore pure system messages.
333        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        // Keep task alive; incoming events are handled by the gateway webhook handler.
418        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
435/// Verify Nextcloud Talk webhook signature.
436///
437/// Signature calculation (official Talk bot docs):
438/// `hex(hmac_sha256(secret, X-Nextcloud-Talk-Random + raw_body))`
439pub 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        // Real payload format sent by Nextcloud Talk bot webhooks.
548        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        // Even if actor.type is not "Application", messages from the configured bot
613        // name must be dropped to prevent feedback loops.
614        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",        // <- wrong type, but name matches
624                "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        // parse_message_payload (legacy format) should also drop actorType=application.
650        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}