Skip to main content

zeroclaw_channels/
nextcloud_talk.rs

1use async_trait::async_trait;
2use hmac::{Hmac, Mac};
3use parking_lot::Mutex;
4use sha2::Sha256;
5use std::collections::HashMap;
6use std::sync::Arc;
7use uuid::Uuid;
8use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
9use zeroclaw_config::schema::StreamMode;
10
11/// Maximum message length accepted by Nextcloud Talk (characters, not bytes).
12/// The OCS API rejects messages longer than 32 000 characters.
13const NC_MAX_MESSAGE_LENGTH: usize = 32_000;
14
15/// Default minimum interval between draft edits when not configured explicitly.
16const DEFAULT_DRAFT_UPDATE_INTERVAL_MS: u64 = 1000;
17
18/// Nextcloud Talk channel in webhook mode.
19///
20/// Incoming messages are received by the gateway endpoint `/nextcloud-talk`.
21/// Outbound replies are sent through Nextcloud Talk OCS API.
22pub struct NextcloudTalkChannel {
23    base_url: String,
24    app_token: String,
25    bot_name: String,
26    /// The alias key under `[channels.nextcloud_talk.<alias>]` this handle is
27    /// bound to. Used to scope peer-group writes and resolver lookups.
28    alias: String,
29    /// Resolves inbound external peers from canonical state at message-time.
30    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
31    peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
32    client: reqwest::Client,
33    /// Controls whether and how streaming draft updates are delivered.
34    stream_mode: StreamMode,
35    /// Minimum interval (ms) between mid-stream draft edits per room.
36    draft_update_interval_ms: u64,
37    /// Tracks the last time a draft-edit was sent per room token, for rate-limiting.
38    last_draft_edit: Mutex<HashMap<String, std::time::Instant>>,
39}
40
41impl NextcloudTalkChannel {
42    pub fn new(
43        base_url: String,
44        app_token: String,
45        bot_name: String,
46        alias: impl Into<String>,
47        peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
48    ) -> Self {
49        Self::new_with_proxy(base_url, app_token, bot_name, alias, peer_resolver, None)
50    }
51
52    pub fn new_with_proxy(
53        base_url: String,
54        app_token: String,
55        bot_name: String,
56        alias: impl Into<String>,
57        peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
58        proxy_url: Option<String>,
59    ) -> Self {
60        Self {
61            base_url: base_url.trim_end_matches('/').to_string(),
62            app_token,
63            bot_name: bot_name.to_ascii_lowercase(),
64            alias: alias.into(),
65            peer_resolver,
66            client: zeroclaw_config::schema::build_channel_proxy_client(
67                "channel.nextcloud_talk",
68                proxy_url.as_deref(),
69            ),
70            stream_mode: StreamMode::Off,
71            draft_update_interval_ms: DEFAULT_DRAFT_UPDATE_INTERVAL_MS,
72            last_draft_edit: Mutex::new(HashMap::new()),
73        }
74    }
75
76    /// Return the alias under `[channels.nextcloud_talk.<alias>]` that this
77    /// channel handle is bound to.
78    pub fn alias(&self) -> &str {
79        &self.alias
80    }
81
82    /// Configure streaming draft-update behaviour.
83    ///
84    /// `mode` — `Off` disables draft updates entirely; `Partial` enables live edits.
85    /// `interval_ms` — minimum delay between consecutive OCS edit calls per room.
86    pub fn with_streaming(mut self, mode: StreamMode, interval_ms: u64) -> Self {
87        self.stream_mode = mode;
88        self.draft_update_interval_ms = interval_ms;
89        self
90    }
91
92    fn is_user_allowed(&self, actor_id: &str) -> bool {
93        let peers = (self.peer_resolver)();
94        crate::allowlist::is_user_allowed(&peers, actor_id, crate::allowlist::Match::Sensitive)
95    }
96
97    /// Returns true if the given name/id belongs to this bot itself.
98    ///
99    /// Prevents feedback loops where ZeroClaw reacts to its own messages.
100    fn is_bot_name(&self, name: &str) -> bool {
101        let name = name.to_ascii_lowercase();
102        // Match the configured bot name, or the known bot name "zeroclaw".
103        (!self.bot_name.is_empty() && name == self.bot_name) || name == "zeroclaw"
104    }
105
106    fn now_unix_secs() -> u64 {
107        std::time::SystemTime::now()
108            .duration_since(std::time::UNIX_EPOCH)
109            .unwrap_or_default()
110            .as_secs()
111    }
112
113    fn parse_timestamp_secs(value: Option<&serde_json::Value>) -> u64 {
114        let raw = match value {
115            Some(serde_json::Value::Number(num)) => num.as_u64(),
116            Some(serde_json::Value::String(s)) => s.trim().parse::<u64>().ok(),
117            _ => None,
118        }
119        .unwrap_or_else(Self::now_unix_secs);
120
121        // Some payloads use milliseconds.
122        if raw > 1_000_000_000_000 {
123            raw / 1000
124        } else {
125            raw
126        }
127    }
128
129    fn value_to_string(value: Option<&serde_json::Value>) -> Option<String> {
130        match value {
131            Some(serde_json::Value::String(s)) => Some(s.clone()),
132            Some(serde_json::Value::Number(n)) => Some(n.to_string()),
133            _ => None,
134        }
135    }
136
137    /// Parse a Nextcloud Talk webhook payload into channel messages.
138    ///
139    /// Two payload formats are supported:
140    ///
141    /// **Format A — legacy/custom** (`type: "message"`):
142    /// ```json
143    /// {
144    ///   "type": "message",
145    ///   "object": { "token": "<room>" },
146    ///   "message": { "actorId": "...", "message": "...", ... }
147    /// }
148    /// ```
149    ///
150    /// **Format B — Activity Streams 2.0** (`type: "Create"`):
151    /// This is the format actually sent by Nextcloud Talk bot webhooks.
152    /// ```json
153    /// {
154    ///   "type": "Create",
155    ///   "actor": { "type": "Person", "id": "users/alice", "name": "Alice" },
156    ///   "object": { "type": "Note", "id": "177", "content": "{\"message\":\"hi\",\"parameters\":[]}", "mediaType": "text/markdown" },
157    ///   "target": { "type": "Collection", "id": "<room_token>", "name": "Room Name" }
158    /// }
159    /// ```
160    pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
161        let messages = Vec::new();
162
163        let event_type = match payload.get("type").and_then(|v| v.as_str()) {
164            Some(t) => t,
165            None => return messages,
166        };
167
168        // Activity Streams 2.0 format sent by Nextcloud Talk bot webhooks.
169        if event_type.eq_ignore_ascii_case("create") {
170            return self.parse_as2_payload(payload);
171        }
172
173        // Legacy/custom format.
174        if !event_type.eq_ignore_ascii_case("message") {
175            ::zeroclaw_log::record!(
176                DEBUG,
177                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
178                    .with_attrs(::serde_json::json!({"event_type": event_type})),
179                "Talk: skipping non-message event"
180            );
181            return messages;
182        }
183
184        self.parse_message_payload(payload)
185    }
186
187    /// Parse Activity Streams 2.0 `Create` payload (real Nextcloud Talk bot webhook format).
188    fn parse_as2_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
189        let mut messages = Vec::new();
190
191        let obj = match payload.get("object") {
192            Some(o) => o,
193            None => return messages,
194        };
195
196        // Only handle Note objects (= chat messages). Ignore reactions, etc.
197        let object_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
198        if !object_type.eq_ignore_ascii_case("note") {
199            ::zeroclaw_log::record!(
200                DEBUG,
201                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
202                    .with_attrs(::serde_json::json!({"object_type": object_type})),
203                "Talk: skipping AS2 Create with object.type="
204            );
205            return messages;
206        }
207
208        // Room token is in target.id.
209        let room_token = payload
210            .get("target")
211            .and_then(|t| t.get("id"))
212            .and_then(|v| v.as_str())
213            .map(str::trim)
214            .filter(|t| !t.is_empty());
215
216        let Some(room_token) = room_token else {
217            ::zeroclaw_log::record!(
218                WARN,
219                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
220                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
221                "Talk: missing target.id (room token) in AS2 payload"
222            );
223            return messages;
224        };
225
226        // Actor — skip bot-originated messages to prevent feedback loops.
227        let actor = payload.get("actor").cloned().unwrap_or_default();
228        let actor_type = actor.get("type").and_then(|v| v.as_str()).unwrap_or("");
229        if actor_type.eq_ignore_ascii_case("application") {
230            ::zeroclaw_log::record!(
231                DEBUG,
232                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
233                "Talk: skipping bot-originated AS2 message (type=Application)"
234            );
235            return messages;
236        }
237
238        // actor.id is "users/<id>" or "bots/<id>" — strip the prefix.
239        let actor_id = actor
240            .get("id")
241            .and_then(|v| v.as_str())
242            .map(|id| {
243                id.trim_start_matches("users/")
244                    .trim_start_matches("bots/")
245                    .trim()
246            })
247            .filter(|id| !id.is_empty());
248
249        let Some(actor_id) = actor_id else {
250            ::zeroclaw_log::record!(
251                WARN,
252                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
253                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
254                "Talk: missing actor.id in AS2 payload"
255            );
256            return messages;
257        };
258
259        // Also skip by actor.id prefix or known bot names — Nextcloud does not always
260        // set actor.type="Application" reliably for bot-sent messages.
261        let raw_actor_id = actor.get("id").and_then(|v| v.as_str()).unwrap_or("");
262        if raw_actor_id.starts_with("bots/") {
263            ::zeroclaw_log::record!(
264                DEBUG,
265                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
266                "Talk: skipping bot-originated AS2 message (id prefix=bots/)"
267            );
268            return messages;
269        }
270        let actor_name = actor
271            .get("name")
272            .and_then(|v| v.as_str())
273            .unwrap_or("")
274            .to_ascii_lowercase();
275        if self.is_bot_name(&actor_name) {
276            ::zeroclaw_log::record!(
277                DEBUG,
278                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
279                    .with_attrs(::serde_json::json!({"actor_name": actor_name})),
280                "Talk: skipping bot-originated AS2 message (name=)"
281            );
282            return messages;
283        }
284
285        if !self.is_user_allowed(actor_id) {
286            ::zeroclaw_log::record!(
287                WARN,
288                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
289                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
290                    .with_attrs(::serde_json::json!({"actor_id": actor_id})),
291                "Talk: ignoring message from unauthorized actor: . Add to channels.nextcloud_talk.allowed_users in config.toml, or run `zeroclaw onboard channels` to configure interactively."
292            );
293            return messages;
294        }
295
296        // Message text is JSON-encoded inside object.content.
297        // e.g. content = "{\"message\":\"hello\",\"parameters\":[]}"
298        let content = obj
299            .get("content")
300            .and_then(|v| v.as_str())
301            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
302            .and_then(|v| {
303                v.get("message")
304                    .and_then(|m| m.as_str())
305                    .map(str::trim)
306                    .map(str::to_string)
307            })
308            .filter(|s| !s.is_empty());
309
310        let Some(content) = content else {
311            ::zeroclaw_log::record!(
312                DEBUG,
313                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
314                "Talk: empty or unparseable AS2 message content"
315            );
316            return messages;
317        };
318
319        let message_id =
320            Self::value_to_string(obj.get("id")).unwrap_or_else(|| Uuid::new_v4().to_string());
321
322        messages.push(ChannelMessage {
323            id: message_id,
324            reply_target: room_token.to_string(),
325            sender: actor_id.to_string(),
326            content,
327            channel: "nextcloud_talk".to_string(),
328            channel_alias: Some(self.alias.clone()),
329            timestamp: Self::now_unix_secs(),
330            thread_ts: None,
331            interruption_scope_id: None,
332            attachments: vec![],
333            subject: None,
334        });
335
336        messages
337    }
338
339    /// Parse legacy `type: "message"` payload format.
340    fn parse_message_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
341        let mut messages = Vec::new();
342
343        let Some(message_obj) = payload.get("message") else {
344            return messages;
345        };
346
347        let room_token = payload
348            .get("object")
349            .and_then(|obj| obj.get("token"))
350            .and_then(|v| v.as_str())
351            .or_else(|| message_obj.get("token").and_then(|v| v.as_str()))
352            .map(str::trim)
353            .filter(|token| !token.is_empty());
354
355        let Some(room_token) = room_token else {
356            ::zeroclaw_log::record!(
357                WARN,
358                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
359                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
360                "Talk: missing room token in webhook payload"
361            );
362            return messages;
363        };
364
365        let actor_type = message_obj
366            .get("actorType")
367            .and_then(|v| v.as_str())
368            .or_else(|| payload.get("actorType").and_then(|v| v.as_str()))
369            .unwrap_or("");
370
371        // Ignore bot-originated messages to prevent feedback loops.
372        // Nextcloud Talk uses "bots" or "application" depending on version/context.
373        if actor_type.eq_ignore_ascii_case("bots") || actor_type.eq_ignore_ascii_case("application")
374        {
375            ::zeroclaw_log::record!(
376                DEBUG,
377                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
378                    .with_attrs(::serde_json::json!({"actor_type": actor_type})),
379                "Talk: skipping bot-originated message (actorType=)"
380            );
381            return messages;
382        }
383
384        let actor_id = message_obj
385            .get("actorId")
386            .and_then(|v| v.as_str())
387            .or_else(|| payload.get("actorId").and_then(|v| v.as_str()))
388            .map(str::trim)
389            .filter(|id| !id.is_empty());
390
391        let Some(actor_id) = actor_id else {
392            ::zeroclaw_log::record!(
393                WARN,
394                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
395                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
396                "Talk: missing actorId in webhook payload"
397            );
398            return messages;
399        };
400
401        // Also skip by known bot names in case actorType is not set reliably.
402        if self.is_bot_name(actor_id) {
403            ::zeroclaw_log::record!(
404                DEBUG,
405                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
406                    .with_attrs(::serde_json::json!({"actor_id": actor_id})),
407                "Talk: skipping bot-originated message (actorId=)"
408            );
409            return messages;
410        }
411
412        if !self.is_user_allowed(actor_id) {
413            ::zeroclaw_log::record!(
414                WARN,
415                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
416                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
417                    .with_attrs(::serde_json::json!({"actor_id": actor_id})),
418                "Talk: ignoring message from unauthorized actor: . Add to channels.nextcloud_talk.allowed_users in config.toml, or run `zeroclaw onboard channels` to configure interactively."
419            );
420            return messages;
421        }
422
423        let message_type = message_obj
424            .get("messageType")
425            .and_then(|v| v.as_str())
426            .unwrap_or("comment");
427        if !message_type.eq_ignore_ascii_case("comment") {
428            ::zeroclaw_log::record!(
429                DEBUG,
430                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
431                    .with_attrs(::serde_json::json!({"message_type": message_type})),
432                "Talk: skipping non-comment messageType"
433            );
434            return messages;
435        }
436
437        // Ignore pure system messages.
438        let has_system_message = message_obj
439            .get("systemMessage")
440            .and_then(|v| v.as_str())
441            .map(str::trim)
442            .is_some_and(|value| !value.is_empty());
443        if has_system_message {
444            ::zeroclaw_log::record!(
445                DEBUG,
446                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
447                "Talk: skipping system message event"
448            );
449            return messages;
450        }
451
452        let content = message_obj
453            .get("message")
454            .and_then(|v| v.as_str())
455            .map(str::trim)
456            .filter(|content| !content.is_empty());
457
458        let Some(content) = content else {
459            return messages;
460        };
461
462        let message_id = Self::value_to_string(message_obj.get("id"))
463            .unwrap_or_else(|| Uuid::new_v4().to_string());
464        let timestamp = Self::parse_timestamp_secs(message_obj.get("timestamp"));
465
466        messages.push(ChannelMessage {
467            id: message_id,
468            reply_target: room_token.to_string(),
469            sender: actor_id.to_string(),
470            content: content.to_string(),
471            channel: "nextcloud_talk".to_string(),
472            channel_alias: Some(self.alias.clone()),
473            timestamp,
474            thread_ts: None,
475            interruption_scope_id: None,
476            attachments: vec![],
477            subject: None,
478        });
479
480        messages
481    }
482
483    async fn send_to_room(&self, room_token: &str, content: &str) -> anyhow::Result<()> {
484        let encoded_room = urlencoding::encode(room_token);
485        let url = format!(
486            "{}/ocs/v2.php/apps/spreed/api/v1/chat/{}?format=json",
487            self.base_url, encoded_room
488        );
489
490        let response = self
491            .client
492            .post(&url)
493            .bearer_auth(&self.app_token)
494            .header("OCS-APIRequest", "true")
495            .header("Accept", "application/json")
496            .json(&serde_json::json!({ "message": content }))
497            .send()
498            .await?;
499
500        if response.status().is_success() {
501            return Ok(());
502        }
503
504        let status = response.status();
505        let body = response.text().await.unwrap_or_default();
506        ::zeroclaw_log::record!(
507            ERROR,
508            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
509                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
510                .with_attrs(::serde_json::json!({"status": status.to_string(), "body": body})),
511            "Talk send failed:"
512        );
513        anyhow::bail!("Talk API error: {status}");
514    }
515
516    /// Send a message and return the numeric message ID assigned by Nextcloud Talk.
517    async fn send_to_room_with_id(
518        &self,
519        room_token: &str,
520        content: &str,
521    ) -> anyhow::Result<String> {
522        let encoded_room = urlencoding::encode(room_token);
523        let url = format!(
524            "{}/ocs/v2.php/apps/spreed/api/v1/chat/{}?format=json",
525            self.base_url, encoded_room
526        );
527
528        let response = self
529            .client
530            .post(&url)
531            .bearer_auth(&self.app_token)
532            .header("OCS-APIRequest", "true")
533            .header("Accept", "application/json")
534            .json(&serde_json::json!({ "message": content }))
535            .send()
536            .await?;
537
538        if !response.status().is_success() {
539            let status = response.status();
540            let body = response.text().await.unwrap_or_default();
541            ::zeroclaw_log::record!(
542                WARN,
543                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
544                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
545                    .with_attrs(::serde_json::json!({"status": status.to_string(), "body": body})),
546                "Talk send_to_room_with_id failed:"
547            );
548            anyhow::bail!("Talk API error: {status}");
549        }
550
551        // Response: { "ocs": { "data": { "id": 42, ... } } }
552        let body: serde_json::Value = response.json().await?;
553        let message_id = body
554            .pointer("/ocs/data/id")
555            .and_then(|v| v.as_u64())
556            .map(|id| id.to_string())
557            .ok_or_else(|| {
558                ::zeroclaw_log::record!(
559                    WARN,
560                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
561                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
562                    "Talk: missing message ID in send response"
563                );
564                anyhow::Error::msg("Talk: missing message ID in send response")
565            })?;
566
567        Ok(message_id)
568    }
569
570    /// Edit an existing message via the Nextcloud Talk OCS API.
571    ///
572    /// `PUT /ocs/v2.php/apps/spreed/api/v1/chat/{token}/{messageId}`
573    async fn edit_message(
574        &self,
575        room_token: &str,
576        message_id: &str,
577        content: &str,
578    ) -> anyhow::Result<()> {
579        let encoded_room = urlencoding::encode(room_token);
580        let url = format!(
581            "{}/ocs/v2.php/apps/spreed/api/v1/chat/{}/{}?format=json",
582            self.base_url, encoded_room, message_id
583        );
584
585        let response = self
586            .client
587            .put(&url)
588            .bearer_auth(&self.app_token)
589            .header("OCS-APIRequest", "true")
590            .header("Accept", "application/json")
591            .json(&serde_json::json!({ "message": content }))
592            .send()
593            .await?;
594
595        if response.status().is_success() {
596            return Ok(());
597        }
598
599        let status = response.status();
600        let body = response.text().await.unwrap_or_default();
601        ::zeroclaw_log::record!(
602            WARN,
603            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
604                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
605                .with_attrs(::serde_json::json!({"status": status.to_string(), "body": body})),
606            "Talk edit_message failed"
607        );
608        anyhow::bail!("Talk edit API error: {status}");
609    }
610
611    /// Delete a message via the Nextcloud Talk OCS API.
612    ///
613    /// `DELETE /ocs/v2.php/apps/spreed/api/v1/chat/{token}/{messageId}`
614    async fn delete_message(&self, room_token: &str, message_id: &str) -> anyhow::Result<()> {
615        let encoded_room = urlencoding::encode(room_token);
616        let url = format!(
617            "{}/ocs/v2.php/apps/spreed/api/v1/chat/{}/{}?format=json",
618            self.base_url, encoded_room, message_id
619        );
620
621        let response = self
622            .client
623            .delete(&url)
624            .bearer_auth(&self.app_token)
625            .header("OCS-APIRequest", "true")
626            .header("Accept", "application/json")
627            .send()
628            .await?;
629
630        if response.status().is_success() {
631            return Ok(());
632        }
633
634        let status = response.status();
635        let body = response.text().await.unwrap_or_default();
636        ::zeroclaw_log::record!(
637            WARN,
638            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
639                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
640                .with_attrs(::serde_json::json!({"status": status.to_string(), "body": body})),
641            "Talk delete_message failed"
642        );
643        anyhow::bail!("Talk delete API error: {status}");
644    }
645
646    /// Truncate text to the Nextcloud Talk character limit (UTF-8 char boundary safe).
647    fn truncate_to_nc_limit(text: &str) -> &str {
648        if text.chars().count() <= NC_MAX_MESSAGE_LENGTH {
649            return text;
650        }
651        // Find the byte offset of the NC_MAX_MESSAGE_LENGTH-th character boundary.
652        let end = text
653            .char_indices()
654            .nth(NC_MAX_MESSAGE_LENGTH)
655            .map(|(idx, _)| idx)
656            .unwrap_or(text.len());
657        &text[..end]
658    }
659}
660
661impl ::zeroclaw_api::attribution::Attributable for NextcloudTalkChannel {
662    fn role(&self) -> ::zeroclaw_api::attribution::Role {
663        ::zeroclaw_api::attribution::Role::Channel(
664            ::zeroclaw_api::attribution::ChannelKind::NextcloudTalk,
665        )
666    }
667    fn alias(&self) -> &str {
668        &self.alias
669    }
670}
671
672#[async_trait]
673impl Channel for NextcloudTalkChannel {
674    fn name(&self) -> &str {
675        "nextcloud_talk"
676    }
677
678    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
679        self.send_to_room(&message.recipient, &message.content)
680            .await
681    }
682
683    fn supports_draft_updates(&self) -> bool {
684        self.stream_mode != StreamMode::Off
685    }
686
687    async fn send_draft(&self, message: &SendMessage) -> anyhow::Result<Option<String>> {
688        if self.stream_mode == StreamMode::Off {
689            return Ok(None);
690        }
691
692        // Send a placeholder "..." message and track its ID for later edits.
693        let initial = if message.content.is_empty() {
694            "..."
695        } else {
696            &message.content
697        };
698        let initial = Self::truncate_to_nc_limit(initial);
699        match self.send_to_room_with_id(&message.recipient, initial).await {
700            Ok(id) => {
701                ::zeroclaw_log::record!(
702                    DEBUG,
703                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
704                        .with_attrs(
705                            ::serde_json::json!({"room": message.recipient, "message_id": id})
706                        ),
707                    "Talk: draft message sent"
708                );
709                self.last_draft_edit
710                    .lock()
711                    .insert(message.recipient.clone(), std::time::Instant::now());
712                Ok(Some(id))
713            }
714            Err(e) => {
715                ::zeroclaw_log::record!(
716                    WARN,
717                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
718                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
719                        .with_attrs(::serde_json::json!({"e": e.to_string()})),
720                    "Talk: send_draft failed, falling back to final send"
721                );
722                Err(e)
723            }
724        }
725    }
726
727    async fn update_draft(
728        &self,
729        recipient: &str,
730        message_id: &str,
731        text: &str,
732    ) -> anyhow::Result<()> {
733        // Rate-limit mid-stream edits per room to avoid hammering the API.
734        {
735            let last_edits = self.last_draft_edit.lock();
736            if let Some(last_time) = last_edits.get(recipient) {
737                let elapsed = u64::try_from(last_time.elapsed().as_millis()).unwrap_or(u64::MAX);
738                if elapsed < self.draft_update_interval_ms {
739                    return Ok(());
740                }
741            }
742        }
743
744        let display_text = Self::truncate_to_nc_limit(text);
745
746        match self.edit_message(recipient, message_id, display_text).await {
747            Ok(()) => {
748                self.last_draft_edit
749                    .lock()
750                    .insert(recipient.to_string(), std::time::Instant::now());
751            }
752            Err(e) => {
753                // Non-fatal: log and continue. The final send will still deliver the
754                // complete response even if mid-stream edits fail.
755                ::zeroclaw_log::record!(
756                    DEBUG,
757                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
758                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
759                    "Talk update_draft skipped"
760                );
761            }
762        }
763
764        Ok(())
765    }
766
767    async fn finalize_draft(
768        &self,
769        recipient: &str,
770        message_id: &str,
771        text: &str,
772    ) -> anyhow::Result<()> {
773        let display_text = Self::truncate_to_nc_limit(text);
774
775        match self.edit_message(recipient, message_id, display_text).await {
776            Ok(()) => {
777                ::zeroclaw_log::record!(
778                    DEBUG,
779                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
780                        .with_attrs(
781                            ::serde_json::json!({"room": recipient, "message_id": message_id})
782                        ),
783                    "Talk: draft finalized"
784                );
785                Ok(())
786            }
787            Err(e) => {
788                // Edit failed (e.g. message too old, permissions) — delete and re-send.
789                ::zeroclaw_log::record!(
790                    WARN,
791                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
792                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
793                        .with_attrs(::serde_json::json!({"e": e.to_string()})),
794                    "Talk finalize_draft edit failed ; attempting delete+resend"
795                );
796                let _ = self.delete_message(recipient, message_id).await;
797                self.send_to_room(recipient, display_text).await
798            }
799        }
800    }
801
802    async fn cancel_draft(&self, recipient: &str, message_id: &str) -> anyhow::Result<()> {
803        if let Err(e) = self.delete_message(recipient, message_id).await {
804            ::zeroclaw_log::record!(
805                DEBUG,
806                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
807                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
808                "Talk cancel_draft delete failed (non-fatal)"
809            );
810        }
811        self.last_draft_edit.lock().remove(recipient);
812        Ok(())
813    }
814
815    async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
816        ::zeroclaw_log::record!(
817            INFO,
818            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
819            "Talk channel active (webhook mode). \
820            Configure Nextcloud Talk bot webhook to POST to your gateway's /nextcloud-talk endpoint."
821        );
822
823        // Keep task alive; incoming events are handled by the gateway webhook handler.
824        loop {
825            tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
826        }
827    }
828
829    async fn health_check(&self) -> bool {
830        let url = format!("{}/status.php", self.base_url);
831
832        self.client
833            .get(&url)
834            .send()
835            .await
836            .map(|r| r.status().is_success())
837            .unwrap_or(false)
838    }
839}
840
841/// Verify Nextcloud Talk webhook signature.
842///
843/// Signature calculation (official Talk bot docs):
844/// `hex(hmac_sha256(secret, X-Nextcloud-Talk-Random + raw_body))`
845pub fn verify_nextcloud_talk_signature(
846    secret: &str,
847    random: &str,
848    body: &str,
849    signature: &str,
850) -> bool {
851    let random = random.trim();
852    if random.is_empty() {
853        ::zeroclaw_log::record!(
854            WARN,
855            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
856                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
857            "Talk: missing X-Nextcloud-Talk-Random header"
858        );
859        return false;
860    }
861
862    let signature_hex = signature
863        .trim()
864        .strip_prefix("sha256=")
865        .unwrap_or(signature)
866        .trim();
867
868    let Ok(provided) = hex::decode(signature_hex) else {
869        ::zeroclaw_log::record!(
870            WARN,
871            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
872                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
873            "Talk: invalid signature format"
874        );
875        return false;
876    };
877
878    let payload = format!("{random}{body}");
879    let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
880        return false;
881    };
882    mac.update(payload.as_bytes());
883
884    mac.verify_slice(&provided).is_ok()
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890
891    #[test]
892    fn nextcloud_talk_channel_name() {
893        let channel = NextcloudTalkChannel::new(
894            "https://cloud.example.com".into(),
895            "app-token".into(),
896            "zeroclaw".into(),
897            "nextcloud_talk_test_alias",
898            Arc::new(|| vec!["user_a".into()]),
899        );
900        assert_eq!(channel.name(), "nextcloud_talk");
901    }
902
903    #[test]
904    fn supports_draft_updates_off_by_default() {
905        // Default construction uses StreamMode::Off → draft updates disabled.
906        let channel = NextcloudTalkChannel::new(
907            "https://cloud.example.com".into(),
908            "app-token".into(),
909            "zeroclaw".into(),
910            "nextcloud_talk_test_alias",
911            Arc::new(|| vec!["user_a".into()]),
912        );
913        assert!(!channel.supports_draft_updates());
914    }
915
916    #[test]
917    fn supports_draft_updates_true_when_partial() {
918        use zeroclaw_config::schema::StreamMode;
919        let channel = NextcloudTalkChannel::new(
920            "https://cloud.example.com".into(),
921            "app-token".into(),
922            "zeroclaw".into(),
923            "nextcloud_talk_test_alias",
924            Arc::new(|| vec!["user_a".into()]),
925        )
926        .with_streaming(StreamMode::Partial, 800);
927        assert!(channel.supports_draft_updates());
928    }
929
930    #[test]
931    fn truncate_to_nc_limit_short_text_unchanged() {
932        let text = "hello";
933        assert_eq!(NextcloudTalkChannel::truncate_to_nc_limit(text), text);
934    }
935
936    #[test]
937    fn truncate_to_nc_limit_exact_limit_unchanged() {
938        let text = "a".repeat(NC_MAX_MESSAGE_LENGTH);
939        let result = NextcloudTalkChannel::truncate_to_nc_limit(&text);
940        assert_eq!(result.len(), NC_MAX_MESSAGE_LENGTH);
941    }
942
943    #[test]
944    fn truncate_to_nc_limit_over_limit_is_truncated() {
945        let text = "a".repeat(NC_MAX_MESSAGE_LENGTH + 100);
946        let result = NextcloudTalkChannel::truncate_to_nc_limit(&text);
947        assert_eq!(result.chars().count(), NC_MAX_MESSAGE_LENGTH);
948    }
949
950    #[test]
951    fn truncate_to_nc_limit_multibyte_safe() {
952        // Each emoji is 4 bytes but 1 char — must not split in the middle.
953        let text = "🦀".repeat(NC_MAX_MESSAGE_LENGTH + 10);
954        let result = NextcloudTalkChannel::truncate_to_nc_limit(&text);
955        assert_eq!(result.chars().count(), NC_MAX_MESSAGE_LENGTH);
956        // Must be valid UTF-8.
957        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
958    }
959
960    #[tokio::test]
961    async fn update_draft_rate_limit_short_circuits_network() {
962        use zeroclaw_config::schema::StreamMode;
963        // Use a large interval (60 s) so the rate-limit always fires immediately.
964        let channel = NextcloudTalkChannel::new(
965            "https://cloud.example.com".into(),
966            "app-token".into(),
967            "zeroclaw".into(),
968            "nextcloud_talk_test_alias",
969            Arc::new(|| vec!["user_a".into()]),
970        )
971        .with_streaming(StreamMode::Partial, 60_000);
972        channel
973            .last_draft_edit
974            .lock()
975            .insert("room-token-123".to_string(), std::time::Instant::now());
976
977        // update_draft should return Ok immediately without hitting the network.
978        let result = channel
979            .update_draft("room-token-123", "42", "some delta")
980            .await;
981        assert!(result.is_ok());
982    }
983
984    #[tokio::test]
985    async fn send_draft_returns_none_when_stream_mode_off() {
986        use zeroclaw_api::channel::SendMessage;
987        // Default mode is Off — send_draft must short-circuit.
988        let channel = NextcloudTalkChannel::new(
989            "https://cloud.example.com".into(),
990            "app-token".into(),
991            "zeroclaw".into(),
992            "nextcloud_talk_test_alias",
993            Arc::new(|| vec!["user_a".into()]),
994        );
995        let result = channel
996            .send_draft(&SendMessage::new("...", "room-token-123"))
997            .await;
998        assert!(result.is_ok());
999        assert!(result.unwrap().is_none());
1000    }
1001
1002    #[test]
1003    fn nextcloud_talk_user_allowlist_exact_and_wildcard() {
1004        let channel = NextcloudTalkChannel::new(
1005            "https://cloud.example.com".into(),
1006            "app-token".into(),
1007            "zeroclaw".into(),
1008            "nextcloud_talk_test_alias",
1009            Arc::new(|| vec!["user_a".into()]),
1010        );
1011        assert!(channel.is_user_allowed("user_a"));
1012        assert!(!channel.is_user_allowed("user_b"));
1013
1014        let wildcard = NextcloudTalkChannel::new(
1015            "https://cloud.example.com".into(),
1016            "app-token".into(),
1017            "zeroclaw".into(),
1018            "nextcloud_talk_test_alias",
1019            Arc::new(|| vec!["*".into()]),
1020        );
1021        assert!(wildcard.is_user_allowed("any_user"));
1022    }
1023
1024    #[test]
1025    fn nextcloud_talk_parse_valid_message_payload() {
1026        let channel = NextcloudTalkChannel::new(
1027            "https://cloud.example.com".into(),
1028            "app-token".into(),
1029            "zeroclaw".into(),
1030            "nextcloud_talk_test_alias",
1031            Arc::new(|| vec!["user_a".into()]),
1032        );
1033        let payload = serde_json::json!({
1034            "type": "message",
1035            "object": {
1036                "id": "42",
1037                "token": "room-token-123",
1038                "name": "Team Room",
1039                "type": "room"
1040            },
1041            "message": {
1042                "id": 77,
1043                "token": "room-token-123",
1044                "actorType": "users",
1045                "actorId": "user_a",
1046                "actorDisplayName": "User A",
1047                "timestamp": 1_735_701_200,
1048                "messageType": "comment",
1049                "systemMessage": "",
1050                "message": "Hello from Nextcloud"
1051            }
1052        });
1053
1054        let messages = channel.parse_webhook_payload(&payload);
1055        assert_eq!(messages.len(), 1);
1056        assert_eq!(messages[0].id, "77");
1057        assert_eq!(messages[0].reply_target, "room-token-123");
1058        assert_eq!(messages[0].sender, "user_a");
1059        assert_eq!(messages[0].content, "Hello from Nextcloud");
1060        assert_eq!(messages[0].channel, "nextcloud_talk");
1061        assert_eq!(messages[0].timestamp, 1_735_701_200);
1062    }
1063
1064    #[test]
1065    fn nextcloud_talk_parse_as2_create_payload() {
1066        let channel = NextcloudTalkChannel::new(
1067            "https://cloud.example.com".into(),
1068            "app-token".into(),
1069            "zeroclaw".into(),
1070            "nextcloud_talk_test_alias",
1071            Arc::new(|| vec!["*".into()]),
1072        );
1073        // Real payload format sent by Nextcloud Talk bot webhooks.
1074        let payload = serde_json::json!({
1075            "type": "Create",
1076            "actor": {
1077                "type": "Person",
1078                "id": "users/user_a",
1079                "name": "User A",
1080                "talkParticipantType": "1"
1081            },
1082            "object": {
1083                "type": "Note",
1084                "id": "177",
1085                "name": "message",
1086                "content": "{\"message\":\"hallo, bist du da?\",\"parameters\":[]}",
1087                "mediaType": "text/markdown"
1088            },
1089            "target": {
1090                "type": "Collection",
1091                "id": "room-token-123",
1092                "name": "HOME"
1093            }
1094        });
1095
1096        let messages = channel.parse_webhook_payload(&payload);
1097        assert_eq!(messages.len(), 1);
1098        assert_eq!(messages[0].reply_target, "room-token-123");
1099        assert_eq!(messages[0].sender, "user_a");
1100        assert_eq!(messages[0].content, "hallo, bist du da?");
1101        assert_eq!(messages[0].channel, "nextcloud_talk");
1102    }
1103
1104    #[test]
1105    fn nextcloud_talk_parse_as2_skips_bot_originated() {
1106        let channel = NextcloudTalkChannel::new(
1107            "https://cloud.example.com".into(),
1108            "app-token".into(),
1109            "zeroclaw".into(),
1110            "nextcloud_talk_test_alias",
1111            Arc::new(|| vec!["*".into()]),
1112        );
1113        let payload = serde_json::json!({
1114            "type": "Create",
1115            "actor": {
1116                "type": "Application",
1117                "id": "bots/zeroclaw",
1118                "name": "zeroclaw"
1119            },
1120            "object": {
1121                "type": "Note",
1122                "id": "178",
1123                "content": "{\"message\":\"I am the bot\",\"parameters\":[]}",
1124                "mediaType": "text/markdown"
1125            },
1126            "target": {
1127                "type": "Collection",
1128                "id": "room-token-123",
1129                "name": "HOME"
1130            }
1131        });
1132
1133        let messages = channel.parse_webhook_payload(&payload);
1134        assert!(messages.is_empty());
1135    }
1136
1137    #[test]
1138    fn nextcloud_talk_parse_as2_skips_bot_by_name() {
1139        // Even if actor.type is not "Application", messages from the configured bot
1140        // name must be dropped to prevent feedback loops.
1141        let channel = NextcloudTalkChannel::new(
1142            "https://cloud.example.com".into(),
1143            "app-token".into(),
1144            "zeroclaw".into(),
1145            "nextcloud_talk_test_alias",
1146            Arc::new(|| vec!["*".into()]),
1147        );
1148        let payload = serde_json::json!({
1149            "type": "Create",
1150            "actor": {
1151                "type": "Person",        // <- wrong type, but name matches
1152                "id": "users/zeroclaw",
1153                "name": "zeroclaw"
1154            },
1155            "object": {
1156                "type": "Note",
1157                "id": "999",
1158                "content": "{\"message\":\"I am the bot\",\"parameters\":[]}",
1159                "mediaType": "text/markdown"
1160            },
1161            "target": {
1162                "type": "Collection",
1163                "id": "room-token-123",
1164                "name": "HOME"
1165            }
1166        });
1167
1168        let messages = channel.parse_webhook_payload(&payload);
1169        assert!(
1170            messages.is_empty(),
1171            "bot message should be filtered even if actor.type is wrong"
1172        );
1173    }
1174
1175    #[test]
1176    fn nextcloud_talk_parse_message_skips_application_actor_type() {
1177        // parse_message_payload (legacy format) should also drop actorType=application.
1178        let channel = NextcloudTalkChannel::new(
1179            "https://cloud.example.com".into(),
1180            "app-token".into(),
1181            "zeroclaw".into(),
1182            "nextcloud_talk_test_alias",
1183            Arc::new(|| vec!["*".into()]),
1184        );
1185        let payload = serde_json::json!({
1186            "type": "message",
1187            "object": {"token": "room-token-123"},
1188            "message": {
1189                "actorType": "application",
1190                "actorId": "zeroclaw",
1191                "message": "Self message"
1192            }
1193        });
1194
1195        let messages = channel.parse_webhook_payload(&payload);
1196        assert!(
1197            messages.is_empty(),
1198            "application actorType must be filtered in legacy format"
1199        );
1200    }
1201
1202    #[test]
1203    fn nextcloud_talk_parse_as2_skips_non_note_objects() {
1204        let channel = NextcloudTalkChannel::new(
1205            "https://cloud.example.com".into(),
1206            "app-token".into(),
1207            "zeroclaw".into(),
1208            "nextcloud_talk_test_alias",
1209            Arc::new(|| vec!["*".into()]),
1210        );
1211        let payload = serde_json::json!({
1212            "type": "Create",
1213            "actor": { "type": "Person", "id": "users/user_a" },
1214            "object": { "type": "Reaction", "id": "5" },
1215            "target": { "type": "Collection", "id": "room-token-123" }
1216        });
1217
1218        let messages = channel.parse_webhook_payload(&payload);
1219        assert!(messages.is_empty());
1220    }
1221
1222    #[test]
1223    fn nextcloud_talk_parse_skips_non_message_events() {
1224        let channel = NextcloudTalkChannel::new(
1225            "https://cloud.example.com".into(),
1226            "app-token".into(),
1227            "zeroclaw".into(),
1228            "nextcloud_talk_test_alias",
1229            Arc::new(|| vec!["user_a".into()]),
1230        );
1231        let payload = serde_json::json!({
1232            "type": "room",
1233            "object": {"token": "room-token-123"},
1234            "message": {
1235                "actorType": "users",
1236                "actorId": "user_a",
1237                "message": "Hello"
1238            }
1239        });
1240
1241        let messages = channel.parse_webhook_payload(&payload);
1242        assert!(messages.is_empty());
1243    }
1244
1245    #[test]
1246    fn nextcloud_talk_parse_skips_bot_messages() {
1247        let channel = NextcloudTalkChannel::new(
1248            "https://cloud.example.com".into(),
1249            "app-token".into(),
1250            "zeroclaw".into(),
1251            "nextcloud_talk_test_alias",
1252            Arc::new(|| vec!["*".into()]),
1253        );
1254        let payload = serde_json::json!({
1255            "type": "message",
1256            "object": {"token": "room-token-123"},
1257            "message": {
1258                "actorType": "bots",
1259                "actorId": "bot_1",
1260                "message": "Self message"
1261            }
1262        });
1263
1264        let messages = channel.parse_webhook_payload(&payload);
1265        assert!(messages.is_empty());
1266    }
1267
1268    #[test]
1269    fn nextcloud_talk_parse_skips_unauthorized_sender() {
1270        let channel = NextcloudTalkChannel::new(
1271            "https://cloud.example.com".into(),
1272            "app-token".into(),
1273            "zeroclaw".into(),
1274            "nextcloud_talk_test_alias",
1275            Arc::new(|| vec!["user_a".into()]),
1276        );
1277        let payload = serde_json::json!({
1278            "type": "message",
1279            "object": {"token": "room-token-123"},
1280            "message": {
1281                "actorType": "users",
1282                "actorId": "user_b",
1283                "message": "Unauthorized"
1284            }
1285        });
1286
1287        let messages = channel.parse_webhook_payload(&payload);
1288        assert!(messages.is_empty());
1289    }
1290
1291    #[test]
1292    fn nextcloud_talk_parse_skips_system_message() {
1293        let channel = NextcloudTalkChannel::new(
1294            "https://cloud.example.com".into(),
1295            "app-token".into(),
1296            "zeroclaw".into(),
1297            "nextcloud_talk_test_alias",
1298            Arc::new(|| vec!["*".into()]),
1299        );
1300        let payload = serde_json::json!({
1301            "type": "message",
1302            "object": {"token": "room-token-123"},
1303            "message": {
1304                "actorType": "users",
1305                "actorId": "user_a",
1306                "messageType": "comment",
1307                "systemMessage": "joined",
1308                "message": ""
1309            }
1310        });
1311
1312        let messages = channel.parse_webhook_payload(&payload);
1313        assert!(messages.is_empty());
1314    }
1315
1316    #[test]
1317    fn nextcloud_talk_parse_timestamp_millis_to_seconds() {
1318        let channel = NextcloudTalkChannel::new(
1319            "https://cloud.example.com".into(),
1320            "app-token".into(),
1321            "zeroclaw".into(),
1322            "nextcloud_talk_test_alias",
1323            Arc::new(|| vec!["*".into()]),
1324        );
1325        let payload = serde_json::json!({
1326            "type": "message",
1327            "object": {"token": "room-token-123"},
1328            "message": {
1329                "actorType": "users",
1330                "actorId": "user_a",
1331                "timestamp": 1_735_701_200_123_u64,
1332                "message": "hello"
1333            }
1334        });
1335
1336        let messages = channel.parse_webhook_payload(&payload);
1337        assert_eq!(messages.len(), 1);
1338        assert_eq!(messages[0].timestamp, 1_735_701_200);
1339    }
1340
1341    const TEST_WEBHOOK_SECRET: &str = "nextcloud_test_webhook_secret";
1342
1343    #[test]
1344    fn nextcloud_talk_signature_verification_valid() {
1345        let secret = TEST_WEBHOOK_SECRET;
1346        let random = "random-seed";
1347        let body = r#"{"type":"message"}"#;
1348
1349        let payload = format!("{random}{body}");
1350        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
1351        mac.update(payload.as_bytes());
1352        let signature = hex::encode(mac.finalize().into_bytes());
1353
1354        assert!(verify_nextcloud_talk_signature(
1355            secret, random, body, &signature
1356        ));
1357    }
1358
1359    #[test]
1360    fn nextcloud_talk_signature_verification_invalid() {
1361        assert!(!verify_nextcloud_talk_signature(
1362            TEST_WEBHOOK_SECRET,
1363            "random-seed",
1364            r#"{"type":"message"}"#,
1365            "deadbeef"
1366        ));
1367    }
1368
1369    #[test]
1370    fn nextcloud_talk_signature_verification_accepts_sha256_prefix() {
1371        let secret = TEST_WEBHOOK_SECRET;
1372        let random = "random-seed";
1373        let body = r#"{"type":"message"}"#;
1374
1375        let payload = format!("{random}{body}");
1376        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
1377        mac.update(payload.as_bytes());
1378        let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
1379
1380        assert!(verify_nextcloud_talk_signature(
1381            secret, random, body, &signature
1382        ));
1383    }
1384}