1use super::intelligence::{ClassifiedMessage, SuggestedAction};
16use crate::config::{AutoReplyMode, IntelligenceConfig, MessagePriority};
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub enum ReplyStatus {
24 Drafting,
26 PendingApproval,
28 Approved,
30 Sent,
32 Rejected,
34 Expired,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct PendingReply {
41 pub id: Uuid,
43 pub channel_name: String,
45 pub recipient: String,
47 pub original_summary: String,
49 pub priority: MessagePriority,
51 pub draft_response: String,
53 pub status: ReplyStatus,
55 pub created_at: DateTime<Utc>,
57 pub updated_at: DateTime<Utc>,
59 pub reasoning: String,
61}
62
63impl PendingReply {
64 pub fn new(
66 channel_name: impl Into<String>,
67 recipient: impl Into<String>,
68 original_summary: impl Into<String>,
69 priority: MessagePriority,
70 ) -> Self {
71 let now = Utc::now();
72 Self {
73 id: Uuid::new_v4(),
74 channel_name: channel_name.into(),
75 recipient: recipient.into(),
76 original_summary: original_summary.into(),
77 priority,
78 draft_response: String::new(),
79 status: ReplyStatus::Drafting,
80 created_at: now,
81 updated_at: now,
82 reasoning: String::new(),
83 }
84 }
85
86 pub fn with_draft(mut self, draft: impl Into<String>) -> Self {
88 self.draft_response = draft.into();
89 self.status = ReplyStatus::PendingApproval;
90 self.updated_at = Utc::now();
91 self
92 }
93
94 pub fn with_reasoning(mut self, reasoning: impl Into<String>) -> Self {
96 self.reasoning = reasoning.into();
97 self
98 }
99
100 pub fn try_approve(&mut self) -> Result<(), &'static str> {
102 match self.status {
103 ReplyStatus::PendingApproval => {
104 self.status = ReplyStatus::Approved;
105 self.updated_at = Utc::now();
106 Ok(())
107 }
108 _ => Err("can only approve a reply in PendingApproval state"),
109 }
110 }
111
112 pub fn try_reject(&mut self) -> Result<(), &'static str> {
114 match self.status {
115 ReplyStatus::PendingApproval | ReplyStatus::Drafting => {
116 self.status = ReplyStatus::Rejected;
117 self.updated_at = Utc::now();
118 Ok(())
119 }
120 _ => Err("can only reject a reply in PendingApproval or Drafting state"),
121 }
122 }
123
124 pub fn try_mark_sent(&mut self) -> Result<(), &'static str> {
126 match self.status {
127 ReplyStatus::Approved => {
128 self.status = ReplyStatus::Sent;
129 self.updated_at = Utc::now();
130 Ok(())
131 }
132 _ => Err("can only mark as sent a reply in Approved state"),
133 }
134 }
135
136 pub fn try_expire(&mut self) -> Result<(), &'static str> {
138 match self.status {
139 ReplyStatus::PendingApproval | ReplyStatus::Drafting => {
140 self.status = ReplyStatus::Expired;
141 self.updated_at = Utc::now();
142 Ok(())
143 }
144 _ => Err("can only expire a reply in PendingApproval or Drafting state"),
145 }
146 }
147
148 pub fn is_actionable(&self) -> bool {
150 matches!(
151 self.status,
152 ReplyStatus::PendingApproval | ReplyStatus::Approved
153 )
154 }
155}
156
157pub struct AutoReplyEngine {
166 config: IntelligenceConfig,
168 pending_replies: Vec<PendingReply>,
170 reply_timestamps: std::collections::VecDeque<DateTime<Utc>>,
172 max_replies_per_hour: usize,
174 max_reply_length: usize,
177}
178
179impl AutoReplyEngine {
180 pub fn new(config: IntelligenceConfig) -> Self {
182 let max_replies = config.max_reply_tokens / 100; Self {
184 config,
185 pending_replies: Vec::new(),
186 reply_timestamps: std::collections::VecDeque::new(),
187 max_replies_per_hour: max_replies.max(10),
188 max_reply_length: 500,
189 }
190 }
191
192 fn is_rate_limited(&mut self) -> bool {
194 let cutoff = Utc::now() - chrono::Duration::hours(1);
195 while self.reply_timestamps.front().is_some_and(|t| *t < cutoff) {
196 self.reply_timestamps.pop_front();
197 }
198 self.reply_timestamps.len() >= self.max_replies_per_hour
199 }
200
201 fn truncate_draft(&self, draft: &str) -> String {
206 if draft.chars().count() > self.max_reply_length {
207 let truncated: String = draft.chars().take(self.max_reply_length).collect();
208 format!("{}...", truncated)
209 } else {
210 draft.to_string()
211 }
212 }
213
214 pub fn process_classified(
219 &mut self,
220 classified: &ClassifiedMessage,
221 channel_name: &str,
222 ) -> Option<PendingReply> {
223 if self.is_rate_limited() {
225 return None;
226 }
227
228 let channel_config = self.config.for_channel(channel_name);
229
230 match &classified.suggested_action {
231 SuggestedAction::AutoReply => {
232 let mut reply =
233 self.create_reply(classified, channel_name, &channel_config.auto_reply);
234 reply.draft_response = self.truncate_draft(&reply.draft_response);
235 Some(reply)
236 }
237 SuggestedAction::DraftReply => {
238 let mut reply =
239 self.create_reply(classified, channel_name, &AutoReplyMode::DraftOnly);
240 reply.draft_response = self.truncate_draft(&reply.draft_response);
241 reply.status = ReplyStatus::PendingApproval;
242 Some(reply)
243 }
244 _ => None,
245 }
246 }
247
248 fn create_reply(
250 &self,
251 classified: &ClassifiedMessage,
252 channel_name: &str,
253 mode: &AutoReplyMode,
254 ) -> PendingReply {
255 let recipient = classified
256 .original
257 .sender
258 .display_name
259 .clone()
260 .unwrap_or_else(|| classified.original.sender.id.clone());
261
262 let original_summary = match &classified.original.content {
263 super::types::MessageContent::Text { text } => {
264 if text.chars().count() > 100 {
265 let truncated: String = text.chars().take(100).collect();
266 format!("{}...", truncated)
267 } else {
268 text.clone()
269 }
270 }
271 _ => format!("{:?}", classified.message_type),
272 };
273
274 let status = match (mode, &classified.priority) {
275 (AutoReplyMode::FullAuto, MessagePriority::Low)
277 | (AutoReplyMode::FullAuto, MessagePriority::Normal) => ReplyStatus::Approved,
278 (AutoReplyMode::FullAuto, _) => ReplyStatus::PendingApproval,
280 (AutoReplyMode::AutoWithApproval, _) => ReplyStatus::PendingApproval,
282 (AutoReplyMode::DraftOnly, _) => ReplyStatus::PendingApproval,
284 (AutoReplyMode::Disabled, _) => ReplyStatus::PendingApproval,
286 };
287
288 let reasoning = format!(
289 "Auto-reply mode={:?}, priority={:?}, type={:?}, classification_confidence={:.2}",
290 mode, classified.priority, classified.message_type, classified.confidence,
291 );
292
293 PendingReply {
294 id: Uuid::new_v4(),
295 channel_name: channel_name.to_string(),
296 recipient,
297 original_summary,
298 priority: classified.priority,
299 draft_response: String::new(), status,
301 created_at: Utc::now(),
302 updated_at: Utc::now(),
303 reasoning,
304 }
305 }
306
307 pub fn enqueue(&mut self, mut reply: PendingReply) {
310 reply.draft_response = self.truncate_draft(&reply.draft_response);
311 self.pending_replies.push(reply);
312 }
313
314 pub fn pending_approval(&self) -> Vec<&PendingReply> {
316 self.pending_replies
317 .iter()
318 .filter(|r| r.status == ReplyStatus::PendingApproval)
319 .collect()
320 }
321
322 pub fn ready_to_send(&self) -> Vec<&PendingReply> {
324 self.pending_replies
325 .iter()
326 .filter(|r| r.status == ReplyStatus::Approved)
327 .collect()
328 }
329
330 pub fn approve_reply(&mut self, id: Uuid) -> bool {
332 if let Some(reply) = self.pending_replies.iter_mut().find(|r| r.id == id) {
333 reply.try_approve().is_ok()
334 } else {
335 false
336 }
337 }
338
339 pub fn reject_reply(&mut self, id: Uuid) -> bool {
341 if let Some(reply) = self.pending_replies.iter_mut().find(|r| r.id == id) {
342 reply.try_reject().is_ok()
343 } else {
344 false
345 }
346 }
347
348 pub fn mark_sent(&mut self, id: Uuid) -> bool {
350 if let Some(reply) = self.pending_replies.iter_mut().find(|r| r.id == id) {
351 if reply.try_mark_sent().is_ok() {
352 self.reply_timestamps.push_back(Utc::now());
353 true
354 } else {
355 false
356 }
357 } else {
358 false
359 }
360 }
361
362 pub fn cleanup_completed(&mut self) -> usize {
364 let before = self.pending_replies.len();
365 self.pending_replies.retain(|r| {
366 !matches!(
367 r.status,
368 ReplyStatus::Sent | ReplyStatus::Rejected | ReplyStatus::Expired
369 )
370 });
371 before - self.pending_replies.len()
372 }
373
374 pub fn pending_count(&self) -> usize {
376 self.pending_replies.len()
377 }
378
379 pub fn sent_count(&self) -> usize {
381 let cutoff = Utc::now() - chrono::Duration::hours(1);
382 self.reply_timestamps
383 .iter()
384 .filter(|t| **t >= cutoff)
385 .count()
386 }
387
388 pub fn reset_rate_limit(&mut self) {
390 self.reply_timestamps.clear();
391 }
392
393 pub fn expire_old_replies(&mut self, max_age_secs: i64) -> usize {
395 let cutoff = Utc::now() - chrono::Duration::seconds(max_age_secs);
396 let mut expired = 0;
397 for reply in &mut self.pending_replies {
398 if reply.created_at < cutoff && reply.try_expire().is_ok() {
399 expired += 1;
400 }
401 }
402 expired
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::channels::intelligence::{ClassifiedMessage, MessageType, SuggestedAction};
410 use crate::channels::types::{
411 ChannelMessage, ChannelType, ChannelUser, MessageContent, MessageId,
412 };
413 use crate::config::{IntelligenceConfig, MessagePriority};
414 use std::collections::HashMap;
415
416 fn make_classified(
417 text: &str,
418 priority: MessagePriority,
419 msg_type: MessageType,
420 action: SuggestedAction,
421 ) -> ClassifiedMessage {
422 let msg = ChannelMessage {
423 id: MessageId::random(),
424 channel_type: ChannelType::Slack,
425 channel_id: "C123".to_string(),
426 sender: ChannelUser::new("alice", ChannelType::Slack).with_name("Alice"),
427 content: MessageContent::Text {
428 text: text.to_string(),
429 },
430 timestamp: Utc::now(),
431 reply_to: None,
432 thread_id: None,
433 metadata: HashMap::new(),
434 };
435 ClassifiedMessage {
436 original: msg,
437 priority,
438 message_type: msg_type,
439 suggested_action: action,
440 confidence: 0.85,
441 reasoning: "test classification".to_string(),
442 classified_at: Utc::now(),
443 }
444 }
445
446 fn default_engine() -> AutoReplyEngine {
447 AutoReplyEngine::new(IntelligenceConfig::default())
448 }
449
450 #[test]
453 fn test_pending_reply_new() {
454 let reply = PendingReply::new("slack", "alice", "Hello?", MessagePriority::Normal);
455 assert_eq!(reply.channel_name, "slack");
456 assert_eq!(reply.recipient, "alice");
457 assert_eq!(reply.status, ReplyStatus::Drafting);
458 assert!(reply.draft_response.is_empty());
459 }
460
461 #[test]
462 fn test_pending_reply_with_draft() {
463 let reply = PendingReply::new("slack", "alice", "Hello?", MessagePriority::Normal)
464 .with_draft("Hi there! How can I help?");
465 assert_eq!(reply.status, ReplyStatus::PendingApproval);
466 assert_eq!(reply.draft_response, "Hi there! How can I help?");
467 }
468
469 #[test]
470 fn test_pending_reply_lifecycle() {
471 let mut reply = PendingReply::new("slack", "alice", "Hello?", MessagePriority::Normal)
472 .with_draft("Reply text");
473
474 assert_eq!(reply.status, ReplyStatus::PendingApproval);
475 assert!(reply.is_actionable());
476
477 reply.try_approve().unwrap();
478 assert_eq!(reply.status, ReplyStatus::Approved);
479 assert!(reply.is_actionable());
480
481 reply.try_mark_sent().unwrap();
482 assert_eq!(reply.status, ReplyStatus::Sent);
483 assert!(!reply.is_actionable());
484 }
485
486 #[test]
487 fn test_pending_reply_reject() {
488 let mut reply = PendingReply::new("slack", "alice", "Hello?", MessagePriority::Normal)
489 .with_draft("Reply text");
490 reply.try_reject().unwrap();
491 assert_eq!(reply.status, ReplyStatus::Rejected);
492 assert!(!reply.is_actionable());
493 }
494
495 #[test]
496 fn test_pending_reply_expire() {
497 let mut reply = PendingReply::new("slack", "alice", "Hello?", MessagePriority::Normal)
498 .with_draft("Reply text");
499 reply.try_expire().unwrap();
500 assert_eq!(reply.status, ReplyStatus::Expired);
501 assert!(!reply.is_actionable());
502 }
503
504 #[test]
505 fn test_try_approve_from_rejected_fails() {
506 let mut reply =
507 PendingReply::new("slack", "alice", "Q", MessagePriority::Normal).with_draft("Reply");
508 reply.try_reject().unwrap();
509 assert!(reply.try_approve().is_err());
510 }
511
512 #[test]
513 fn test_try_mark_sent_from_pending_fails() {
514 let mut reply =
515 PendingReply::new("slack", "alice", "Q", MessagePriority::Normal).with_draft("Reply");
516 assert!(reply.try_mark_sent().is_err());
517 }
518
519 #[test]
520 fn test_try_expire_from_sent_fails() {
521 let mut reply =
522 PendingReply::new("slack", "alice", "Q", MessagePriority::Normal).with_draft("Reply");
523 reply.try_approve().unwrap();
524 reply.try_mark_sent().unwrap();
525 assert!(reply.try_expire().is_err());
526 }
527
528 #[test]
529 fn test_valid_full_lifecycle_path() {
530 let mut reply = PendingReply::new("slack", "alice", "Q", MessagePriority::Normal);
531 assert_eq!(reply.status, ReplyStatus::Drafting);
532 reply = reply.with_draft("Draft text");
534 assert_eq!(reply.status, ReplyStatus::PendingApproval);
535 reply.try_approve().unwrap();
537 assert_eq!(reply.status, ReplyStatus::Approved);
538 reply.try_mark_sent().unwrap();
540 assert_eq!(reply.status, ReplyStatus::Sent);
541 }
542
543 #[test]
546 fn test_engine_process_auto_reply_full_auto_normal() {
547 let mut engine = default_engine();
548 let classified = make_classified(
549 "What time is the meeting?",
550 MessagePriority::Normal,
551 MessageType::Question,
552 SuggestedAction::AutoReply,
553 );
554 let reply = engine.process_classified(&classified, "slack");
555 assert!(reply.is_some());
556 let reply = reply.unwrap();
557 assert_eq!(reply.status, ReplyStatus::Approved);
559 assert_eq!(reply.recipient, "Alice");
560 }
561
562 #[test]
563 fn test_engine_process_auto_reply_full_auto_urgent() {
564 let mut engine = default_engine();
565 let classified = make_classified(
566 "URGENT: production is down",
567 MessagePriority::Urgent,
568 MessageType::Question,
569 SuggestedAction::AutoReply,
570 );
571 let reply = engine.process_classified(&classified, "slack");
572 assert!(reply.is_some());
573 let reply = reply.unwrap();
574 assert_eq!(reply.status, ReplyStatus::PendingApproval);
576 }
577
578 #[test]
579 fn test_engine_process_draft_reply() {
580 let mut engine = default_engine();
581 let classified = make_classified(
582 "Can you explain how this works?",
583 MessagePriority::Normal,
584 MessageType::Question,
585 SuggestedAction::DraftReply,
586 );
587 let reply = engine.process_classified(&classified, "email");
588 assert!(reply.is_some());
589 let reply = reply.unwrap();
590 assert_eq!(reply.status, ReplyStatus::PendingApproval);
591 }
592
593 #[test]
594 fn test_engine_process_non_reply_action() {
595 let mut engine = default_engine();
596 let classified = make_classified(
597 "Interesting news",
598 MessagePriority::Low,
599 MessageType::Notification,
600 SuggestedAction::AddToDigest,
601 );
602 let reply = engine.process_classified(&classified, "slack");
603 assert!(reply.is_none());
604 }
605
606 #[test]
607 fn test_engine_process_ignore_action() {
608 let mut engine = default_engine();
609 let classified = make_classified(
610 "spam spam spam",
611 MessagePriority::Low,
612 MessageType::Spam,
613 SuggestedAction::Ignore,
614 );
615 let reply = engine.process_classified(&classified, "slack");
616 assert!(reply.is_none());
617 }
618
619 #[test]
620 fn test_engine_enqueue_and_query() {
621 let mut engine = default_engine();
622 let reply1 = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
623 .with_draft("Reply 1");
624 let reply2 =
625 PendingReply::new("slack", "bob", "Q2", MessagePriority::Normal).with_draft("Reply 2");
626
627 engine.enqueue(reply1);
628 engine.enqueue(reply2);
629
630 assert_eq!(engine.pending_count(), 2);
631 assert_eq!(engine.pending_approval().len(), 2);
632 assert_eq!(engine.ready_to_send().len(), 0);
633 }
634
635 #[test]
636 fn test_engine_approve_and_send() {
637 let mut engine = default_engine();
638 let reply = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
639 .with_draft("Reply 1");
640 let id = reply.id;
641 engine.enqueue(reply);
642
643 assert!(engine.approve_reply(id));
644 assert_eq!(engine.ready_to_send().len(), 1);
645
646 assert!(engine.mark_sent(id));
647 assert_eq!(engine.sent_count(), 1);
648 assert_eq!(engine.ready_to_send().len(), 0);
649 }
650
651 #[test]
652 fn test_engine_reject_reply() {
653 let mut engine = default_engine();
654 let reply = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
655 .with_draft("Reply 1");
656 let id = reply.id;
657 engine.enqueue(reply);
658
659 assert!(engine.reject_reply(id));
660 assert_eq!(engine.pending_approval().len(), 0);
661 }
662
663 #[test]
664 fn test_engine_approve_nonexistent() {
665 let mut engine = default_engine();
666 assert!(!engine.approve_reply(Uuid::new_v4()));
667 }
668
669 #[test]
670 fn test_engine_cleanup_completed() {
671 let mut engine = default_engine();
672
673 let mut reply1 = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
674 .with_draft("Reply 1");
675 reply1.try_approve().unwrap();
676 reply1.try_mark_sent().unwrap();
677 engine.enqueue(reply1);
678
679 let mut reply2 =
680 PendingReply::new("slack", "bob", "Q2", MessagePriority::Normal).with_draft("Reply 2");
681 reply2.try_reject().unwrap();
682 engine.enqueue(reply2);
683
684 let reply3 = PendingReply::new("slack", "carol", "Q3", MessagePriority::Normal)
685 .with_draft("Reply 3");
686 engine.enqueue(reply3);
687
688 assert_eq!(engine.pending_count(), 3);
689 let cleaned = engine.cleanup_completed();
690 assert_eq!(cleaned, 2);
691 assert_eq!(engine.pending_count(), 1);
692 }
693
694 #[test]
695 fn test_engine_rate_limiting() {
696 let config = IntelligenceConfig {
697 max_reply_tokens: 200, ..IntelligenceConfig::default()
699 };
700 let mut engine = AutoReplyEngine::new(config);
701 for _ in 0..10 {
704 engine.reply_timestamps.push_back(Utc::now());
705 }
706 let classified = make_classified(
707 "What time?",
708 MessagePriority::Normal,
709 MessageType::Question,
710 SuggestedAction::AutoReply,
711 );
712 let reply = engine.process_classified(&classified, "slack");
713 assert!(reply.is_none(), "Should be rate limited");
714 }
715
716 #[test]
717 fn test_engine_reset_rate_limit() {
718 let mut engine = default_engine();
719 for _ in 0..5 {
720 engine.reply_timestamps.push_back(Utc::now());
721 }
722 engine.reset_rate_limit();
723 assert_eq!(engine.sent_count(), 0);
724 }
725
726 #[test]
727 fn test_engine_rate_limit_window_expiry() {
728 let config = IntelligenceConfig {
729 max_reply_tokens: 200,
730 ..IntelligenceConfig::default()
731 };
732 let mut engine = AutoReplyEngine::new(config);
733 let old = Utc::now() - chrono::Duration::hours(2);
735 for _ in 0..10 {
736 engine.reply_timestamps.push_back(old);
737 }
738 let classified = make_classified(
740 "What time?",
741 MessagePriority::Normal,
742 MessageType::Question,
743 SuggestedAction::AutoReply,
744 );
745 let reply = engine.process_classified(&classified, "slack");
746 assert!(
747 reply.is_some(),
748 "Old timestamps should have expired from the window"
749 );
750 }
751
752 #[test]
753 fn test_engine_expire_old_replies() {
754 let mut engine = default_engine();
755 let mut reply = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
756 .with_draft("Reply 1");
757 reply.created_at = Utc::now() - chrono::Duration::hours(2);
759 engine.enqueue(reply);
760
761 let expired = engine.expire_old_replies(3600); assert_eq!(expired, 1);
763 assert_eq!(engine.pending_approval().len(), 0);
764 }
765
766 #[test]
767 fn test_engine_expire_does_not_expire_recent() {
768 let mut engine = default_engine();
769 let reply = PendingReply::new("slack", "alice", "Q1", MessagePriority::Normal)
770 .with_draft("Reply 1");
771 engine.enqueue(reply);
772
773 let expired = engine.expire_old_replies(3600);
774 assert_eq!(expired, 0);
775 assert_eq!(engine.pending_approval().len(), 1);
776 }
777}