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
11const NC_MAX_MESSAGE_LENGTH: usize = 32_000;
14
15const DEFAULT_DRAFT_UPDATE_INTERVAL_MS: u64 = 1000;
17
18pub struct NextcloudTalkChannel {
23 base_url: String,
24 app_token: String,
25 bot_name: String,
26 alias: String,
29 peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
32 client: reqwest::Client,
33 stream_mode: StreamMode,
35 draft_update_interval_ms: u64,
37 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 pub fn alias(&self) -> &str {
79 &self.alias
80 }
81
82 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 fn is_bot_name(&self, name: &str) -> bool {
101 let name = name.to_ascii_lowercase();
102 (!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 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 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 if event_type.eq_ignore_ascii_case("create") {
170 return self.parse_as2_payload(payload);
171 }
172
173 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn truncate_to_nc_limit(text: &str) -> &str {
648 if text.chars().count() <= NC_MAX_MESSAGE_LENGTH {
649 return text;
650 }
651 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 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 {
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 ::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 ::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 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
841pub 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 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 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 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 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 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 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 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 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", "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 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}